feat: update auto reply and packaging

This commit is contained in:
ly1213
2026-06-29 17:44:22 +08:00
parent 1ca66dc0af
commit 2d5ee7f08d
19 changed files with 1147 additions and 227 deletions

View File

@@ -120,6 +120,9 @@ Section
File "runtime\Helper_*.dll" File "runtime\Helper_*.dll"
File "runtime\Loader_*.dll" File "runtime\Loader_*.dll"
SetOutPath "$INSTDIR\tools"
File /r "runtime\tools\*.*"
SetOutPath "$INSTDIR\requestdata" SetOutPath "$INSTDIR\requestdata"
File /r "runtime\requestdata\*.*" File /r "runtime\requestdata\*.*"

View File

@@ -17,7 +17,7 @@
], ],
"materialType": "image", "materialType": "image",
"path": "猫猫图片.jpg", "path": "猫猫图片.jpg",
"caption": "我把图片发。", "caption": "我把图片发给您。",
"priority": 3, "priority": 3,
"enabled": true "enabled": true
}, },
@@ -39,7 +39,7 @@
], ],
"materialType": "file", "materialType": "file",
"path": "售后问题库_2026-05-30_1629.xlsx", "path": "售后问题库_2026-05-30_1629.xlsx",
"caption": "我把售后问题表发。", "caption": "我把售后问题表发给您。",
"priority": 2, "priority": 2,
"enabled": true "enabled": true
}, },
@@ -61,7 +61,7 @@
], ],
"materialType": "file", "materialType": "file",
"path": "方案模板.docx", "path": "方案模板.docx",
"caption": "我把方案模板发。", "caption": "我把方案模板发给您。",
"priority": 2, "priority": 2,
"enabled": true "enabled": true
} }

View File

@@ -129,6 +129,7 @@ type HandoffConfig struct {
type IdentityConfig struct { type IdentityConfig struct {
UnknownPolicy string `json:"unknownPolicy"` UnknownPolicy string `json:"unknownPolicy"`
UnknownHandoffPolicy string `json:"unknownHandoffPolicy"` UnknownHandoffPolicy string `json:"unknownHandoffPolicy"`
ReplyExternalOnly bool `json:"replyExternalOnly"`
RefreshOnStart bool `json:"refreshOnStart"` RefreshOnStart bool `json:"refreshOnStart"`
RefreshIntervalMinutes int `json:"refreshIntervalMinutes"` RefreshIntervalMinutes int `json:"refreshIntervalMinutes"`
PageSize int `json:"pageSize"` PageSize int `json:"pageSize"`
@@ -272,6 +273,7 @@ func NewDefaultAutoReplyConfig() AutoReplyConfig {
Identity: IdentityConfig{ Identity: IdentityConfig{
UnknownPolicy: "customer", UnknownPolicy: "customer",
UnknownHandoffPolicy: "hold", UnknownHandoffPolicy: "hold",
ReplyExternalOnly: false,
RefreshOnStart: true, RefreshOnStart: true,
RefreshIntervalMinutes: 30, RefreshIntervalMinutes: 30,
PageSize: 200, PageSize: 200,

View File

@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<div v-if="sectionAlerts('listen').length" class="section-alerts"> <div v-if="sectionAlerts('listen').length" class="section-alerts">
<div v-for="alert in sectionAlerts('listen')" :key="alert" class="section-alert">{{ alert }}</div> <div v-for="alert in sectionAlerts('listen')" :key="alert.text" class="section-alert" :class="alert.type">{{ alert.text }}</div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label class="check-row"> <label class="check-row">
@@ -117,6 +117,10 @@
<input type="checkbox" v-model="form.listen.ignoreSelfMessage"> <input type="checkbox" v-model="form.listen.ignoreSelfMessage">
<span>忽略自己发送的消息</span> <span>忽略自己发送的消息</span>
</label> </label>
<label class="check-row">
<input type="checkbox" v-model="form.identity.replyExternalOnly">
<span>只回复外部客户忽略企业内部成员</span>
</label>
<label> <label>
<span>群聊触发</span> <span>群聊触发</span>
<select v-model="form.listen.groupTriggerMode"> <select v-model="form.listen.groupTriggerMode">
@@ -169,7 +173,7 @@
</div> </div>
</div> </div>
<div v-if="sectionAlerts('ai').length" class="section-alerts"> <div v-if="sectionAlerts('ai').length" class="section-alerts">
<div v-for="alert in sectionAlerts('ai')" :key="alert" class="section-alert">{{ alert }}</div> <div v-for="alert in sectionAlerts('ai')" :key="alert.text" class="section-alert" :class="alert.type">{{ alert.text }}</div>
</div> </div>
<!-- 万川平台配置卡片 --> <!-- 万川平台配置卡片 -->
@@ -345,7 +349,7 @@
</div> </div>
</div> </div>
<div v-if="sectionAlerts('knowledge').length" class="section-alerts"> <div v-if="sectionAlerts('knowledge').length" class="section-alerts">
<div v-for="alert in sectionAlerts('knowledge')" :key="alert" class="section-alert">{{ alert }}</div> <div v-for="alert in sectionAlerts('knowledge')" :key="alert.text" class="section-alert" :class="alert.type">{{ alert.text }}</div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label class="wide"> <label class="wide">
@@ -473,7 +477,7 @@
</div> </div>
</div> </div>
<div v-if="sectionAlerts('handoff').length" class="section-alerts"> <div v-if="sectionAlerts('handoff').length" class="section-alerts">
<div v-for="alert in sectionAlerts('handoff')" :key="alert" class="section-alert">{{ alert }}</div> <div v-for="alert in sectionAlerts('handoff')" :key="alert.text" class="section-alert" :class="alert.type">{{ alert.text }}</div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label> <label>
@@ -549,7 +553,7 @@
</div> </div>
</div> </div>
<div v-if="sectionAlerts('identity').length" class="section-alerts"> <div v-if="sectionAlerts('identity').length" class="section-alerts">
<div v-for="alert in sectionAlerts('identity')" :key="alert" class="section-alert">{{ alert }}</div> <div v-for="alert in sectionAlerts('identity')" :key="alert.text" class="section-alert" :class="alert.type">{{ alert.text }}</div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label> <label>
@@ -770,7 +774,7 @@
</div> </div>
</div> </div>
<div v-if="sectionAlerts('records').length" class="section-alerts"> <div v-if="sectionAlerts('records').length" class="section-alerts">
<div v-for="alert in sectionAlerts('records')" :key="alert" class="section-alert">{{ alert }}</div> <div v-for="alert in sectionAlerts('records')" :key="alert.text" class="section-alert" :class="alert.type">{{ alert.text }}</div>
</div> </div>
<div class="record-table"> <div class="record-table">
<div class="record-row header"> <div class="record-row header">
@@ -1056,46 +1060,72 @@ function classifyStatusErrorScope(text, scope) {
return knownSectionScope(scope) || classifyErrorScope(text) return knownSectionScope(scope) || classifyErrorScope(text)
} }
function addUniqueAlert(alerts, text) { function normalizeMessageType(type) {
const normalized = String(type || '').trim().toLowerCase()
if (normalized === 'success' || normalized === 'warn' || normalized === 'warning') {
return normalized === 'warning' ? 'warn' : normalized
}
return 'error'
}
function addUniqueAlert(alerts, text, type = 'error') {
const normalized = String(text || '').trim() const normalized = String(text || '').trim()
if (!normalized) return if (!normalized) return
const index = alerts.findIndex(item => item === normalized || item.includes(normalized) || normalized.includes(item)) const messageType = normalizeMessageType(type)
const index = alerts.findIndex(item => item.text === normalized || item.text.includes(normalized) || normalized.includes(item.text))
if (index < 0) { if (index < 0) {
alerts.push(normalized) alerts.push({ text: normalized, type: messageType })
} else if (normalized.length > alerts[index].length) { } else if (normalized.length > alerts[index].text.length || alerts[index].type !== 'error') {
alerts[index] = normalized alerts[index] = { text: normalized, type: messageType }
} }
} }
function sectionAlerts(sectionId) { function sectionAlerts(sectionId) {
const scope = normalizeSectionScope(sectionId) const scope = normalizeSectionScope(sectionId)
const alerts = [] const alerts = []
addUniqueAlert(alerts, scopedMessages.value[scope]) const scopedMessage = scopedMessages.value[scope]
addUniqueAlert(alerts, scopedMessage?.text || scopedMessage, scopedMessage?.type || 'error')
if (scope === 'identity') { if (scope === 'identity') {
addUniqueAlert(alerts, status.value.identityRefreshError) addUniqueAlert(alerts, status.value.identityRefreshError, 'error')
addUniqueAlert(alerts, status.value.internalGroupMemberSyncError) addUniqueAlert(alerts, status.value.internalGroupMemberSyncError, 'error')
} }
const lastError = String(status.value.lastError || '').trim() const lastError = String(status.value.lastError || '').trim()
if (lastError && classifyStatusErrorScope(lastError, status.value.lastErrorScope) === scope) { if (lastError && classifyStatusErrorScope(lastError, status.value.lastErrorScope) === scope) {
addUniqueAlert(alerts, lastError) addUniqueAlert(alerts, lastError, 'error')
} }
return alerts return alerts
} }
function setScopedMessage(scope, text) { function clearScopedMessage(scope) {
const target = normalizeSectionScope(scope) const target = normalizeSectionScope(scope)
const normalized = String(text || '').trim()
if (!normalized) return
scopedMessages.value = { ...scopedMessages.value, [target]: normalized }
if (scopedMessageTimers[target]) { if (scopedMessageTimers[target]) {
clearTimeout(scopedMessageTimers[target]) clearTimeout(scopedMessageTimers[target])
scopedMessageTimers[target] = null
} }
scopedMessageTimers[target] = setTimeout(() => { if (scopedMessages.value[target]) {
if (scopedMessages.value[target] !== normalized) return
const next = { ...scopedMessages.value } const next = { ...scopedMessages.value }
delete next[target] delete next[target]
scopedMessages.value = next scopedMessages.value = next
}
}
function setScopedMessage(scope, text, type = 'error') {
const target = normalizeSectionScope(scope)
const normalized = String(text || '').trim()
if (!normalized) return
const messageType = normalizeMessageType(type)
scopedMessages.value = { ...scopedMessages.value, [target]: { text: normalized, type: messageType } }
if (scopedMessageTimers[target]) {
clearTimeout(scopedMessageTimers[target])
}
if (messageType === 'error') {
scopedMessageTimers[target] = null scopedMessageTimers[target] = null
return
}
scopedMessageTimers[target] = setTimeout(() => {
const current = scopedMessages.value[target]
if (!current || current.text !== normalized || current.type !== messageType) return
clearScopedMessage(target)
}, 5000) }, 5000)
} }
@@ -1181,6 +1211,7 @@ function defaultConfig() {
identity: { identity: {
unknownPolicy: 'customer', unknownPolicy: 'customer',
unknownHandoffPolicy: 'hold', unknownHandoffPolicy: 'hold',
replyExternalOnly: false,
refreshOnStart: true, refreshOnStart: true,
refreshIntervalMinutes: 30, refreshIntervalMinutes: 30,
pageSize: 200, pageSize: 200,
@@ -2399,7 +2430,7 @@ async function saveConfig(scope = 'listen', silentSuccess = false) {
return false return false
} }
if (!silentSuccess) { if (!silentSuccess) {
notify('自动客服配置已保存', 'success') notify('自动客服配置已保存', 'success', scope)
} }
await loadStatus() await loadStatus()
return true return true
@@ -2421,7 +2452,7 @@ async function handleStart() {
await SaveAutoReplyConfig(JSON.stringify(form)) await SaveAutoReplyConfig(JSON.stringify(form))
await SetAutoReplyEnabled(true) await SetAutoReplyEnabled(true)
await SendWxWorkData('0', JSON.stringify({ type: 10000, data: {} })) await SendWxWorkData('0', JSON.stringify({ type: 10000, data: {} }))
notify('自动客服已开启,正在监听当前接管账号', 'success') notify('自动客服已开启,正在监听当前接管账号', 'success', 'listen')
await loadStatus() await loadStatus()
} catch (err) { } catch (err) {
notify(`开启失败: ${err.message || err}`, 'error', 'listen') notify(`开启失败: ${err.message || err}`, 'error', 'listen')
@@ -2439,7 +2470,7 @@ async function handleDisable() {
normalizeHandoffBeforeSave() normalizeHandoffBeforeSave()
await SaveAutoReplyConfig(JSON.stringify(form)) await SaveAutoReplyConfig(JSON.stringify(form))
await SetAutoReplyEnabled(false) await SetAutoReplyEnabled(false)
notify('自动客服已关闭', 'success') notify('自动客服已关闭', 'success', 'listen')
await loadStatus() await loadStatus()
} catch (err) { } catch (err) {
notify(`关闭失败: ${err.message || err}`, 'error', 'listen') notify(`关闭失败: ${err.message || err}`, 'error', 'listen')
@@ -2463,6 +2494,7 @@ async function rebuildKnowledge() {
notify('知识库索引已重建,但未扫描到任何知识文件,请确认知识目录和文件格式是否正确。', 'error', 'knowledge') notify('知识库索引已重建,但未扫描到任何知识文件,请确认知识目录和文件格式是否正确。', 'error', 'knowledge')
} else { } else {
const failedSuffix = failedCount > 0 ? `${failedCount} 个文件解析失败` : '' const failedSuffix = failedCount > 0 ? `${failedCount} 个文件解析失败` : ''
if (failedCount === 0) clearScopedMessage('knowledge')
notify(`知识库索引已重建:${fileCount} 个文件、${chunkCount} 个分片${failedSuffix}`, failedCount > 0 ? 'error' : 'success', 'knowledge') notify(`知识库索引已重建:${fileCount} 个文件、${chunkCount} 个分片${failedSuffix}`, failedCount > 0 ? 'error' : 'success', 'knowledge')
} }
} else { } else {
@@ -2574,8 +2606,14 @@ async function testHandoff() {
function notify(text, type = 'success', scope = '') { function notify(text, type = 'success', scope = '') {
const normalized = String(text || '').trim() const normalized = String(text || '').trim()
if (!normalized) return if (!normalized) return
const target = knownSectionScope(scope)
if (target) {
if (type !== 'error') clearScopedMessage(target)
setScopedMessage(target, normalized, type)
return
}
if (type === 'error') { if (type === 'error') {
setScopedMessage(scope || classifyErrorScope(normalized), normalized) setScopedMessage(classifyErrorScope(normalized), normalized, type)
return return
} }
message.value = normalized message.value = normalized
@@ -2897,6 +2935,21 @@ button:disabled {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.section-alert.success {
background: #e8f7ef;
color: #17633a;
}
.section-alert.warn {
background: #fff7df;
color: #7a4a00;
}
.section-alert.error {
background: #fdecec;
color: #9b1c1c;
}
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -3597,6 +3650,24 @@ button:disabled {
border: 1px solid rgba(255, 107, 125, 0.28); border: 1px solid rgba(255, 107, 125, 0.28);
} }
.section-alert.success {
color: var(--cmd-green);
background: rgba(63, 255, 162, 0.1);
border-color: rgba(63, 255, 162, 0.28);
}
.section-alert.warn {
color: var(--cmd-amber);
background: rgba(255, 209, 102, 0.1);
border-color: rgba(255, 209, 102, 0.3);
}
.section-alert.error {
color: var(--cmd-red);
background: rgba(255, 107, 125, 0.1);
border-color: rgba(255, 107, 125, 0.28);
}
.field-warning { .field-warning {
border-color: rgba(255, 209, 102, 0.34); border-color: rgba(255, 209, 102, 0.34);
color: var(--cmd-amber); color: var(--cmd-amber);

View File

@@ -211,6 +211,7 @@ if (typeof window.go === 'undefined') {
identity: { identity: {
unknownPolicy: 'customer', unknownPolicy: 'customer',
unknownHandoffPolicy: 'hold', unknownHandoffPolicy: 'hold',
replyExternalOnly: false,
refreshOnStart: true, refreshOnStart: true,
refreshIntervalMinutes: 30, refreshIntervalMinutes: 30,
pageSize: 200, pageSize: 200,

View File

@@ -247,84 +247,3 @@ export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files // Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -48,10 +48,6 @@ export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames); return window.runtime.EventsOff(eventName, ...additionalEventNames);
} }
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) { export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1); return EventsOnMultiple(eventName, callback, 1);
} }
@@ -240,59 +236,3 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) { export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files); return window.runtime.ResolveFilePaths(files);
} }
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

View File

@@ -125,6 +125,10 @@ func (e *AutoReplyEngine) processJob(job AutoReplyJob) {
if name := e.displayNameForMessage(msg); name != "" { if name := e.displayNameForMessage(msg); name != "" {
msg.FromNickName = name msg.FromNickName = name
} }
if cfg.Identity.ReplyExternalOnly && identity.isInternal() {
e.ignoreMessage(msg, "internal_ignored_external_only")
return
}
if identity.Source == identitySourceUnknownAsCustomer { if identity.Source == identitySourceUnknownAsCustomer {
e.noteReason(identitySourceUnknownAsCustomer) e.noteReason(identitySourceUnknownAsCustomer)
if !msg.IsGroup { if !msg.IsGroup {
@@ -269,7 +273,11 @@ func (e *AutoReplyEngine) processJob(job AutoReplyJob) {
} }
e.setLastRetrievalScores(searchResult.KeywordScore, searchResult.VectorScore, searchResult.RerankScore) e.setLastRetrievalScores(searchResult.KeywordScore, searchResult.VectorScore, searchResult.RerankScore)
if len(materialMatches) > 0 { if len(materialMatches) > 0 {
if err := e.sendMaterials(msg, materialMatches, "materials_replied", withSearchMetadata(currentTimings(), searchResult)); err != nil { materialMatches = e.filterRecentlySentMaterials(msg, materialMatches)
}
if len(materialMatches) > 0 {
tutorialText := materialTutorialTextFromHits(hits)
if err := e.sendMaterials(msg, materialMatches, "materials_replied", withSearchMetadata(currentTimings(), searchResult), tutorialText); err != nil {
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "material send failed: "+err.Error()) e.setLastErrorWithScope(autoReplyErrorScopeRecords, "material send failed: "+err.Error())
} }
return return

View File

@@ -54,9 +54,18 @@ func handleAutoReplyRebuildKnowledge(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
idx, err := getAutoReplyEngine().rebuildKnowledgeIndex() idx, err := getAutoReplyEngine().rebuildKnowledgeIndex()
if err != nil { if err != nil {
data := map[string]interface{}{
"durationMs": time.Since(start).Milliseconds(),
}
if idx != nil {
data["fileCount"] = idx.FileCount
data["chunkCount"] = len(idx.Chunks)
data["failedFiles"] = idx.FailedFiles
}
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{ sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{
"success": false, "success": false,
"message": err.Error(), "message": err.Error(),
"data": data,
}) })
return return
} }

View File

@@ -162,9 +162,18 @@ func (e *AutoReplyEngine) rebuildKnowledgeIndex() (*KnowledgeIndex, error) {
return nil, err return nil, err
} }
e.updateKnowledgeStatus(idx) e.updateKnowledgeStatus(idx)
if err := e.rebuildEmbeddingIndex(idx); err != nil { if requiresEmbeddingIndex(cfg.Retrieval.RetrievalMode) {
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, err.Error()) if err := e.rebuildEmbeddingIndex(idx); err != nil {
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, err.Error())
return idx, err
}
if len(idx.Chunks) > 0 && e.embeddingEntryCount() == 0 {
err := fmt.Errorf("向量索引为空,请先重建知识库索引")
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, err.Error())
return idx, err
}
} }
e.clearLastErrorScope(autoReplyErrorScopeKnowledge)
return idx, nil return idx, nil
} }
@@ -206,6 +215,19 @@ func (e *AutoReplyEngine) updateKnowledgeStatus(idx *KnowledgeIndex) {
e.mu.Unlock() e.mu.Unlock()
} }
func (e *AutoReplyEngine) embeddingEntryCount() int {
e.mu.Lock()
defer e.mu.Unlock()
if e.embeddingIndex == nil {
return 0
}
return len(e.embeddingIndex.Entries)
}
func requiresEmbeddingIndex(mode string) bool {
return strings.TrimSpace(mode) != retrievalModeKeywordOnly
}
func scoreKnowledgeChunk(queryTokens map[string]int, chunk KnowledgeChunk) float64 { func scoreKnowledgeChunk(queryTokens map[string]int, chunk KnowledgeChunk) float64 {
textTokens := tokenizeKnowledgeText(chunk.Title + " " + chunk.Content) textTokens := tokenizeKnowledgeText(chunk.Title + " " + chunk.Content)
if len(textTokens) == 0 { if len(textTokens) == 0 {

View File

@@ -79,8 +79,8 @@ func TestRebuildKnowledgeIndexScansSubdirectories(t *testing.T) {
// 根目录故意不放任何知识文件,全部放进多层子目录。 // 根目录故意不放任何知识文件,全部放进多层子目录。
files := map[string]string{ files := map[string]string{
filepath.Join("01_产品", "数控机床", "VMC850规格.md"): "VMC850 立式加工中心,主轴转速 8000rpm。", filepath.Join("01_产品", "数控机床", "VMC850规格.md"): "VMC850 立式加工中心,主轴转速 8000rpm。",
filepath.Join("03_售后", "故障排查", "常见故障.md"): "报警 E01 表示伺服过载,请检查负载。", filepath.Join("03_售后", "故障排查", "常见故障.md"): "报警 E01 表示伺服过载,请检查负载。",
filepath.Join("readme.txt"): "", // 空文件,应进 FailedFiles 不计入 FileCount filepath.Join("readme.txt"): "", // 空文件,应进 FailedFiles 不计入 FileCount
} }
for rel, content := range files { for rel, content := range files {
full := filepath.Join(dir, rel) full := filepath.Join(dir, rel)
@@ -96,6 +96,7 @@ func TestRebuildKnowledgeIndexScansSubdirectories(t *testing.T) {
cfg.Knowledge.Directory = dir cfg.Knowledge.Directory = dir
cfg.Knowledge.IndexPath = filepath.Join(dir, "index.json") cfg.Knowledge.IndexPath = filepath.Join(dir, "index.json")
cfg.Retrieval.EmbeddingIndexPath = filepath.Join(dir, "embedding_index.json") cfg.Retrieval.EmbeddingIndexPath = filepath.Join(dir, "embedding_index.json")
cfg.Retrieval.RetrievalMode = retrievalModeKeywordOnly
engine := testAutoReplyEngine(cfg) engine := testAutoReplyEngine(cfg)
idx, err := engine.rebuildKnowledgeIndex() idx, err := engine.rebuildKnowledgeIndex()
@@ -118,6 +119,56 @@ func TestRebuildKnowledgeIndexScansSubdirectories(t *testing.T) {
} }
} }
func TestRebuildKnowledgeIndexClearsKnowledgeLastErrorOnSuccess(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "faq.md"), []byte("AgentBox supports knowledge search."), 0644); err != nil {
t.Fatalf("write knowledge file failed: %v", 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")
cfg.Retrieval.RetrievalMode = retrievalModeKeywordOnly
engine := testAutoReplyEngine(cfg)
engine.setLastErrorWithScope(autoReplyErrorScopeKnowledge, "向量召回失败,已降级关键词检索: test")
if _, err := engine.rebuildKnowledgeIndex(); err != nil {
t.Fatalf("rebuildKnowledgeIndex failed: %v", err)
}
if engine.status.LastError != "" || engine.status.LastErrorScope != "" {
t.Fatalf("expected knowledge error cleared, got scope=%q error=%q", engine.status.LastErrorScope, engine.status.LastError)
}
}
func TestRebuildKnowledgeIndexKeepsKnowledgeLastErrorWhenEmbeddingFails(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "faq.md"), []byte("AgentBox supports knowledge search."), 0644); err != nil {
t.Fatalf("write knowledge file failed: %v", 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")
cfg.Retrieval.RetrievalMode = retrievalModeHybridRerank
cfg.AI.BaseURL = ""
cfg.AI.APIKey = ""
engine := testAutoReplyEngine(cfg)
engine.setLastErrorWithScope(autoReplyErrorScopeKnowledge, "向量召回失败,已降级关键词检索: test")
idx, err := engine.rebuildKnowledgeIndex()
if err == nil {
t.Fatal("expected embedding rebuild failure")
}
if idx == nil || idx.FileCount != 1 {
t.Fatalf("expected scanned index returned with one file, got %#v", idx)
}
if engine.status.LastErrorScope != autoReplyErrorScopeKnowledge || engine.status.LastError == "" {
t.Fatalf("expected knowledge error retained, got scope=%q error=%q", engine.status.LastErrorScope, engine.status.LastError)
}
}
func TestParsePDFKnowledgeFileExtractsTextLayer(t *testing.T) { func TestParsePDFKnowledgeFileExtractsTextLayer(t *testing.T) {
path := filepath.Join(t.TempDir(), "text.pdf") path := filepath.Join(t.TempDir(), "text.pdf")
writeMinimalTextPDF(t, path, "AgentBox PDF content 123") writeMinimalTextPDF(t, path, "AgentBox PDF content 123")

View File

@@ -131,7 +131,7 @@ func generateMaterialCaptionByChat(aiCfg config.AIConfig, provider string, userP
func materialCaptionSystemPrompt() string { func materialCaptionSystemPrompt() string {
return "你是企业微信里的真人客服,现在要把一份资料顺手发给客户。请写一句自然口语的开场白," + return "你是企业微信里的真人客服,现在要把一份资料顺手发给客户。请写一句自然口语的开场白," +
"要求①只有一句话不超过40字②像微信里手发东西时说的话,亲切自然,不要书面腔和客服模板腔" + "要求①只有一句话不超过40字使用“您”,像微信里手发东西时说的话,亲切自然,不要书面腔和客服模板腔" +
"(不要用“您好”“为您提供”“请查收”这类);③结合资料内容点出这是什么、对客户有什么用;" + "(不要用“您好”“为您提供”“请查收”这类);③结合资料内容点出这是什么、对客户有什么用;" +
"④不要编造资料里没有的信息;⑤只输出这句话本身,不要加引号、解释或多余标点。" "④不要编造资料里没有的信息;⑤只输出这句话本身,不要加引号、解释或多余标点。"
} }

View File

@@ -10,9 +10,12 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"unicode" "unicode"
) )
const autoReplyMaterialRepeatWindow = 30 * time.Minute
type AutoReplyMaterial struct { type AutoReplyMaterial struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -56,9 +59,6 @@ func (e *AutoReplyEngine) matchMaterials(userQuery string, searchContext string,
if !cfg.Materials.AutoSendEnabled { if !cfg.Materials.AutoSendEnabled {
return nil return nil
} }
if isBroadAllMaterialRequest(userQuery) {
return nil
}
materials, err := loadAutoReplyMaterials(cfg.Materials.IndexPath) materials, err := loadAutoReplyMaterials(cfg.Materials.IndexPath)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
@@ -74,7 +74,23 @@ func (e *AutoReplyEngine) matchMaterials(userQuery string, searchContext string,
requestedTypes := requestedMaterialTypes(userQuery) requestedTypes := requestedMaterialTypes(userQuery)
hasSendIntent := hasMaterialSendIntent(userQuery) hasSendIntent := hasMaterialSendIntent(userQuery)
if hasSendIntent && isGenericMaterialRequest(userQuery) && !materialQueryHasSpecificSignal(userQuery, materials) && strings.TrimSpace(searchContext) == strings.TrimSpace(userQuery) { explicitAll := isBroadAllMaterialRequest(userQuery)
hasSpecificSignal := materialQueryHasSpecificSignal(userQuery, materials)
if !hasSendIntent && !explicitAll {
return nil
}
if explicitAll {
if hasSpecificSignal {
queryText := buildMaterialSearchText(userQuery, "", nil, false)
return e.collectMaterialMatches(materials, cfg.Materials.Directory, requestedTypesForExplicitAll(userQuery, requestedTypes), queryText, true)
}
filteredTypes := requestedTypesForExplicitAll(userQuery, requestedTypes)
if len(filteredTypes) > 0 {
return e.collectMaterialMatchesByType(materials, cfg.Materials.Directory, filteredTypes)
}
return nil
}
if hasSendIntent && isGenericMaterialRequest(userQuery) && !hasSpecificSignal && strings.TrimSpace(searchContext) == strings.TrimSpace(userQuery) {
return nil return nil
} }
queryText := buildMaterialSearchText(userQuery, "", nil, false) queryText := buildMaterialSearchText(userQuery, "", nil, false)
@@ -122,6 +138,86 @@ func (e *AutoReplyEngine) collectMaterialMatches(materials []AutoReplyMaterial,
return matches return matches
} }
func (e *AutoReplyEngine) collectMaterialMatchesByType(materials []AutoReplyMaterial, root string, requestedTypes map[string]bool) []autoReplyMaterialMatch {
if len(requestedTypes) == 0 {
return nil
}
matches := make([]autoReplyMaterialMatch, 0, len(materials))
for _, material := range materials {
if !requestedTypes[material.MaterialType] {
continue
}
path := resolveAutoReplyMaterialPath(root, material.Path)
if _, err := os.Stat(path); err != nil {
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, fmt.Sprintf("material file missing: %s", path))
continue
}
matches = append(matches, autoReplyMaterialMatch{Material: material, Path: path, Score: 1})
}
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].Material.Priority != matches[j].Material.Priority {
return matches[i].Material.Priority > matches[j].Material.Priority
}
return strings.ToLower(matches[i].Material.Path) < strings.ToLower(matches[j].Material.Path)
})
return matches
}
func (e *AutoReplyEngine) filterRecentlySentMaterials(msg autoReplyMessage, matches []autoReplyMaterialMatch) []autoReplyMaterialMatch {
if len(matches) == 0 {
return nil
}
now := time.Now()
e.mu.Lock()
if e.materialSent == nil {
e.materialSent = make(map[string]time.Time)
}
for key, ts := range e.materialSent {
if now.Sub(ts) > autoReplyMaterialRepeatWindow {
delete(e.materialSent, key)
}
}
filtered := make([]autoReplyMaterialMatch, 0, len(matches))
for _, match := range matches {
key := materialSentFingerprint(msg, match)
if key == "" {
filtered = append(filtered, match)
continue
}
if ts, ok := e.materialSent[key]; ok && now.Sub(ts) <= autoReplyMaterialRepeatWindow {
continue
}
filtered = append(filtered, match)
}
e.mu.Unlock()
return filtered
}
func (e *AutoReplyEngine) rememberSentMaterial(msg autoReplyMessage, match autoReplyMaterialMatch) {
key := materialSentFingerprint(msg, match)
if key == "" {
return
}
e.mu.Lock()
if e.materialSent == nil {
e.materialSent = make(map[string]time.Time)
}
e.materialSent[key] = time.Now()
e.mu.Unlock()
}
func materialSentFingerprint(msg autoReplyMessage, match autoReplyMaterialMatch) string {
conversationID := strings.TrimSpace(msg.ConversationID)
path := strings.ToLower(filepath.Clean(strings.TrimSpace(match.Path)))
if path == "." || path == "" {
path = strings.ToLower(filepath.Clean(strings.TrimSpace(match.Material.Path)))
}
if conversationID == "" || path == "" || path == "." {
return ""
}
return strings.Join([]string{msg.stableRobotID(), conversationID, path}, "|")
}
func limitMaterialMatches(matches []autoReplyMaterialMatch, maxPerReply int) []autoReplyMaterialMatch { func limitMaterialMatches(matches []autoReplyMaterialMatch, maxPerReply int) []autoReplyMaterialMatch {
limit := maxPerReply limit := maxPerReply
if limit <= 0 { if limit <= 0 {
@@ -192,6 +288,29 @@ func requestedMaterialTypes(query string) map[string]bool {
return result return result
} }
func requestedTypesForExplicitAll(query string, requestedTypes map[string]bool) map[string]bool {
if len(requestedTypes) == 0 {
return nil
}
if len(requestedTypes) == 1 && requestedTypes["file"] && !explicitlyRequestsOnlyFiles(query) {
return nil
}
return requestedTypes
}
func explicitlyRequestsOnlyFiles(query string) bool {
text := normalizeGreetingText(query)
if text == "" {
return false
}
for _, token := range []string{"文档", "表格", "手册", "说明书", "ppt", "pdf", "doc", "docx", "xls", "xlsx"} {
if strings.Contains(text, normalizeGreetingText(token)) {
return true
}
}
return false
}
func containsAnyMaterialIntent(text string, keywords []string) bool { func containsAnyMaterialIntent(text string, keywords []string) bool {
for _, keyword := range keywords { for _, keyword := range keywords {
if strings.Contains(text, keyword) { if strings.Contains(text, keyword) {
@@ -632,30 +751,37 @@ func inferMaterialType(path string) string {
} }
} }
func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoReplyMaterialMatch, reason string, timings autoReplyTimings) error { func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoReplyMaterialMatch, reason string, timings autoReplyTimings, tutorialText string) error {
if len(matches) == 0 { if len(matches) == 0 {
return nil return nil
} }
captions := make([]string, 0, len(matches))
for _, match := range matches {
if caption := customMaterialCaptionForSend(match.Material); caption != "" {
captions = append(captions, caption)
}
}
if len(captions) == 0 {
captions = append(captions, combinedMaterialCaption(matches))
}
caption := strings.Join(uniqueMaterialStrings(captions), "\n")
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, caption); err != nil {
return err
}
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, caption)
sent := make([]string, 0, len(matches)) sent := make([]string, 0, len(matches))
if text := strings.TrimSpace(tutorialText); text != "" {
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, text); err != nil {
return err
}
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, text)
}
for _, match := range matches { for _, match := range matches {
caption := materialCaptionForSend(match.Material)
captionAfter := materialCaptionShouldFollowMaterial(match.Material)
if !captionAfter {
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, caption); err != nil {
return err
}
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, caption)
}
if err := sendAutoReplyMaterial(uint32(msg.ClientID), msg.ConversationID, match.Material.MaterialType, match.Path); err != nil { if err := sendAutoReplyMaterial(uint32(msg.ClientID), msg.ConversationID, match.Material.MaterialType, match.Path); err != nil {
return fmt.Errorf("send material %s failed: %w", match.Path, err) return fmt.Errorf("send material %s failed: %w", match.Path, err)
} }
e.rememberSentMaterial(msg, match)
sent = append(sent, fmt.Sprintf("%s:%s", match.Material.MaterialType, match.Path)) sent = append(sent, fmt.Sprintf("%s:%s", match.Material.MaterialType, match.Path))
if captionAfter {
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, caption); err != nil {
return err
}
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, caption)
}
} }
e.markCooldown(msg) e.markCooldown(msg)
e.incStatus("replied") e.incStatus("replied")
@@ -689,6 +815,15 @@ func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoRepl
return nil return nil
} }
func materialCaptionShouldFollowMaterial(material AutoReplyMaterial) bool {
switch strings.ToLower(strings.TrimSpace(material.MaterialType)) {
case "image", "video", "gif":
return true
default:
return false
}
}
func materialCaptionForSend(material AutoReplyMaterial) string { func materialCaptionForSend(material AutoReplyMaterial) string {
if caption := customMaterialCaptionForSend(material); caption != "" { if caption := customMaterialCaptionForSend(material); caption != "" {
return caption return caption
@@ -696,6 +831,45 @@ func materialCaptionForSend(material AutoReplyMaterial) string {
return defaultMaterialCaption(material.MaterialType) return defaultMaterialCaption(material.MaterialType)
} }
func materialTutorialTextFromHits(hits []KnowledgeChunk) string {
items := make([]string, 0, 3)
for _, hit := range hits {
content := strings.TrimSpace(hit.Content)
if content == "" {
continue
}
content = compactMaterialTutorialContent(content)
if content == "" {
continue
}
items = append(items, content)
if len(items) >= 3 {
break
}
}
if len(items) == 0 {
return ""
}
return "我也把相关排查说明整理给您:\n" + strings.Join(items, "\n")
}
func compactMaterialTutorialContent(content string) string {
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
result := make([]string, 0, 4)
for _, line := range lines {
line = strings.TrimSpace(strings.Trim(line, "#>*- \t"))
if line == "" {
continue
}
result = append(result, line)
if len(result) >= 4 {
break
}
}
text := strings.Join(result, "\n")
return truncateText(text, 280)
}
func customMaterialCaptionForSend(material AutoReplyMaterial) string { func customMaterialCaptionForSend(material AutoReplyMaterial) string {
caption := strings.TrimSpace(material.Caption) caption := strings.TrimSpace(material.Caption)
if caption != "" && !isLegacyGenericMaterialCaption(caption) { if caption != "" && !isLegacyGenericMaterialCaption(caption) {
@@ -718,19 +892,19 @@ func isLegacyGenericMaterialCaption(caption string) bool {
func defaultMaterialCaption(materialType string) string { func defaultMaterialCaption(materialType string) string {
switch strings.ToLower(strings.TrimSpace(materialType)) { switch strings.ToLower(strings.TrimSpace(materialType)) {
case "image": case "image":
return "我把图片发。" return "我把图片发给您。"
case "video": case "video":
return "我把视频发。" return "我把视频发给您。"
case "gif": case "gif":
return "我把动图发。" return "我把动图发给您。"
default: default:
return "我把文件发。" return "我把文件发给您。"
} }
} }
func combinedMaterialCaption(matches []autoReplyMaterialMatch) string { func combinedMaterialCaption(matches []autoReplyMaterialMatch) string {
if len(matches) == 0 { if len(matches) == 0 {
return "我把文件发。" return "我把文件发给您。"
} }
seen := map[string]bool{} seen := map[string]bool{}
labels := make([]string, 0, 4) labels := make([]string, 0, 4)
@@ -755,7 +929,7 @@ func combinedMaterialCaption(matches []autoReplyMaterialMatch) string {
if len(labels) == 1 { if len(labels) == 1 {
return defaultMaterialCaption(matches[0].Material.MaterialType) return defaultMaterialCaption(matches[0].Material.MaterialType)
} }
return "我把" + strings.Join(labels, "和") + "发。" return "我把" + strings.Join(labels, "和") + "发给您。"
} }
func uniqueMaterialStrings(items []string) []string { func uniqueMaterialStrings(items []string) []string {
@@ -805,6 +979,13 @@ func sendAutoReplyMaterialRequest(clientID uint32, conversationID string, materi
"file": path, "file": path,
}, },
} }
if messageType == 11031 {
if fileName := strings.TrimSpace(filepath.Base(path)); fileName != "" && fileName != "." {
data := request["data"].(map[string]interface{})
data["fileName"] = fileName
data["file_name"] = fileName
}
}
data, err := json.Marshal(request) data, err := json.Marshal(request)
if err != nil { if err != nil {
return err return err

View File

@@ -121,6 +121,7 @@ type AutoReplyEngine struct {
humanPending map[string]*humanAssistPending humanPending map[string]*humanAssistPending
collaborations map[string]*collaborationSession collaborations map[string]*collaborationSession
autoSent map[string]time.Time autoSent map[string]time.Time
materialSent map[string]time.Time
records []AutoReplyRecord records []AutoReplyRecord
nextRecordID int64 nextRecordID int64
status AutoReplyStatus status AutoReplyStatus
@@ -177,6 +178,7 @@ func initAutoReplyEngine() {
humanPending: make(map[string]*humanAssistPending), humanPending: make(map[string]*humanAssistPending),
collaborations: make(map[string]*collaborationSession), collaborations: make(map[string]*collaborationSession),
autoSent: make(map[string]time.Time), autoSent: make(map[string]time.Time),
materialSent: make(map[string]time.Time),
status: AutoReplyStatus{ status: AutoReplyStatus{
Enabled: cfg.Enabled, Enabled: cfg.Enabled,
Running: cfg.Enabled, Running: cfg.Enabled,
@@ -318,6 +320,19 @@ func (e *AutoReplyEngine) setLastErrorWithScope(scope string, msg string) {
} }
} }
func (e *AutoReplyEngine) clearLastErrorScope(scope string) {
scope = normalizeAutoReplyErrorScope(scope)
if scope == "" {
return
}
e.mu.Lock()
if e.status.LastErrorScope == scope {
e.status.LastError = ""
e.status.LastErrorScope = ""
}
e.mu.Unlock()
}
func normalizeAutoReplyErrorScope(scope string) string { func normalizeAutoReplyErrorScope(scope string) string {
switch strings.TrimSpace(scope) { switch strings.TrimSpace(scope) {
case autoReplyErrorScopeListen: case autoReplyErrorScopeListen:

View File

@@ -31,6 +31,8 @@ func testAutoReplyEngine(cfg config.AutoReplyConfig) *AutoReplyEngine {
identityGroups: make(map[int32]map[string]autoReplyGroupOption), identityGroups: make(map[int32]map[string]autoReplyGroupOption),
contextEntries: make(map[string][]autoReplyContextEntry), contextEntries: make(map[string][]autoReplyContextEntry),
collaborations: make(map[string]*collaborationSession), collaborations: make(map[string]*collaborationSession),
autoSent: make(map[string]time.Time),
materialSent: make(map[string]time.Time),
status: AutoReplyStatus{ status: AutoReplyStatus{
ReasonCounts: make(map[string]int), ReasonCounts: make(map[string]int),
}, },
@@ -313,7 +315,7 @@ func TestSendMaterialsRoutesByMessageClientID(t *testing.T) {
Score: 10, Score: 10,
}} }}
if err := engine.sendMaterials(msg, matches, "material_sent", autoReplyTimings{}); err != nil { if err := engine.sendMaterials(msg, matches, "material_sent", autoReplyTimings{}, ""); err != nil {
t.Fatalf("sendMaterials failed: %v", err) t.Fatalf("sendMaterials failed: %v", err)
} }
if textClient != 42 || textConversation != msg.ConversationID || textContent != "安装视频发你。" { if textClient != 42 || textConversation != msg.ConversationID || textContent != "安装视频发你。" {
@@ -334,11 +336,11 @@ func TestMaterialDefaultCaptionsByType(t *testing.T) {
caption string caption string
want string want string
}{ }{
{name: "image", materialType: "image", want: "我把图片发。"}, {name: "image", materialType: "image", want: "我把图片发给您。"},
{name: "video", materialType: "video", want: "我把视频发。"}, {name: "video", materialType: "video", want: "我把视频发给您。"},
{name: "gif", materialType: "gif", want: "我把动图发。"}, {name: "gif", materialType: "gif", want: "我把动图发给您。"},
{name: "file", materialType: "file", want: "我把文件发。"}, {name: "file", materialType: "file", want: "我把文件发给您。"},
{name: "legacy generic", materialType: "image", caption: "我把相关资料直接发你。", want: "我把图片发。"}, {name: "legacy generic", materialType: "image", caption: "我把相关资料直接发你。", want: "我把图片发给您。"},
{name: "custom", materialType: "video", caption: "安装视频发你。", want: "安装视频发你。"}, {name: "custom", materialType: "video", caption: "安装视频发你。", want: "安装视频发你。"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -360,7 +362,7 @@ func TestCombinedMaterialCaptionMergesTypes(t *testing.T) {
{Material: AutoReplyMaterial{MaterialType: "image"}}, {Material: AutoReplyMaterial{MaterialType: "image"}},
{Material: AutoReplyMaterial{MaterialType: "video"}}, {Material: AutoReplyMaterial{MaterialType: "video"}},
} }
if got := combinedMaterialCaption(matches); got != "我把图片和视频发。" { if got := combinedMaterialCaption(matches); got != "我把图片和视频发给您。" {
t.Fatalf("expected merged type caption, got %q", got) t.Fatalf("expected merged type caption, got %q", got)
} }
} }
@@ -393,10 +395,10 @@ func TestSendMaterialsUsesTypedDefaultCaption(t *testing.T) {
Score: 10, Score: 10,
}} }}
if err := engine.sendMaterials(msg, matches, "material_sent", autoReplyTimings{}); err != nil { if err := engine.sendMaterials(msg, matches, "material_sent", autoReplyTimings{}, ""); err != nil {
t.Fatalf("sendMaterials failed: %v", err) t.Fatalf("sendMaterials failed: %v", err)
} }
if sentText != "我把图片发。" { if sentText != "我把图片发给您。" {
t.Fatalf("expected typed image caption, got %q", sentText) t.Fatalf("expected typed image caption, got %q", sentText)
} }
} }
@@ -433,6 +435,45 @@ func TestDiscoverAutoReplyMaterialsScansDirectory(t *testing.T) {
} }
} }
func TestFolderMaterialRequestLimitsUnlessExplicitAll(t *testing.T) {
dir := t.TempDir()
folder := filepath.Join(dir, "0505尾部切刀")
if err := os.MkdirAll(folder, 0755); err != nil {
t.Fatalf("make folder: %v", err)
}
for _, name := range []string{"气压检查.jpg", "拉膜皮带.mp4", "刀槽清洁.pdf"} {
if err := os.WriteFile(filepath.Join(folder, name), []byte("material"), 0644); err != nil {
t.Fatalf("write material %s: %v", name, err)
}
}
cfg := config.NewDefaultAutoReplyConfig()
cfg.Materials.Directory = dir
cfg.Materials.IndexPath = filepath.Join(dir, "missing-materials.json")
cfg.Materials.MaxPerReply = 1
engine := testAutoReplyEngine(cfg)
matches := engine.matchMaterials("0505尾部切刀切不断怎么排查", "0505尾部切刀切不断怎么排查", nil)
if len(matches) != 0 {
t.Fatalf("expected normal troubleshooting request to diagnose first instead of sending materials, got %#v", matches)
}
matches = engine.matchMaterials("把0505尾部切刀排查视频发我", "把0505尾部切刀排查视频发我", nil)
if len(matches) != 1 {
t.Fatalf("expected explicit troubleshooting material request to respect maxPerReply, got %#v", matches)
}
allMatches := engine.matchMaterials("0505尾部切刀的资料全部发我", "0505尾部切刀的资料全部发我", nil)
if len(allMatches) != 3 {
t.Fatalf("expected explicit all request for matched folder to send all folder files, got %#v", allMatches)
}
genericAll := engine.matchMaterials("全部资料都发我", "全部资料都发我", nil)
if len(genericAll) != 0 {
t.Fatalf("generic all-material request should still require clarification, got %#v", genericAll)
}
}
func TestSyncAutoReplyMaterialsAddsRemovesAndKeepsExistingConfig(t *testing.T) { func TestSyncAutoReplyMaterialsAddsRemovesAndKeepsExistingConfig(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "kept.pdf"), []byte("pdf"), 0644); err != nil { if err := os.WriteFile(filepath.Join(dir, "kept.pdf"), []byte("pdf"), 0644); err != nil {
@@ -566,6 +607,64 @@ func TestMaterialTypeIntentFiltersMatches(t *testing.T) {
} }
} }
func TestAfterSalesAlarmMaterialRequiresExplicitSendIntent(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "立式机编码器报警3排查.mp4"), []byte("video"), 0644); err != nil {
t.Fatalf("write alarm3 material: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "立式机编码器报警5排查.mp4"), []byte("video"), 0644); err != nil {
t.Fatalf("write alarm5 material: %v", err)
}
indexPath := filepath.Join(dir, "materials.json")
materials := autoReplyMaterialsFile{Materials: []AutoReplyMaterial{
{
ID: "alarm3-video",
Title: "立式机编码器报警3排查",
Keywords: []string{"立式机编码器报警3", "报警3排查"},
MaterialType: "video",
Path: "立式机编码器报警3排查.mp4",
Caption: "报警3排查视频",
Enabled: true,
},
{
ID: "alarm5-video",
Title: "立式机编码器报警5排查",
Keywords: []string{"立式机编码器报警5", "报警5排查"},
MaterialType: "video",
Path: "立式机编码器报警5排查.mp4",
Caption: "报警5排查视频",
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)
if matches := engine.matchMaterials("立式机编码器报警", "立式机编码器报警\n视频封面识别控制屏显示报警", nil); len(matches) != 0 {
t.Fatalf("expected alarm diagnosis without send intent not to send materials, got %#v", matches)
}
matches := engine.matchMaterials("把立式机编码器报警3排查视频发我", "把立式机编码器报警3排查视频发我", nil)
if len(matches) != 1 || matches[0].Material.ID != "alarm3-video" {
t.Fatalf("expected only alarm3 video, got %#v", matches)
}
matches = engine.matchMaterials("把立式机编码器报警5排查视频发我", "把立式机编码器报警5排查视频发我", nil)
if len(matches) != 1 || matches[0].Material.ID != "alarm5-video" {
t.Fatalf("expected only alarm5 video, got %#v", matches)
}
}
func TestMaterialSendIntentUsesContextWithoutFullFilename(t *testing.T) { func TestMaterialSendIntentUsesContextWithoutFullFilename(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
fileName := "企业级AI数字员工解决方案宣传手册(无标价版)(1).pptx" fileName := "企业级AI数字员工解决方案宣传手册(无标价版)(1).pptx"
@@ -1818,13 +1917,13 @@ func TestQuestionReferencesContext(t *testing.T) {
question string question string
want bool want bool
}{ }{
{"它多少钱", true}, // 它多少钱 {"它多少钱", true}, // 它多少钱
{"这个怎么用", true}, // 这个怎么用 {"这个怎么用", true}, // 这个怎么用
{"刚才那个再说说", true}, // 刚才那个再说说 {"刚才那个再说说", true}, // 刚才那个再说说
{"继续", true}, // 继续 {"继续", true}, // 继续
{"今天星期几", false}, // 今天星期几 {"今天星期几", false}, // 今天星期几
{"你们有什么产品", false}, // 你们有什么产品 {"你们有什么产品", false}, // 你们有什么产品
{"换个话题吧", false}, // 换个话题吧 {"换个话题吧", false}, // 换个话题吧
} }
for _, c := range cases { for _, c := range cases {
if got := questionReferencesContext(c.question); got != c.want { if got := questionReferencesContext(c.question); got != c.want {
@@ -1838,11 +1937,11 @@ func TestIsPureTopicSwitchMessage(t *testing.T) {
content string content string
want bool want bool
}{ }{
{"换个话题吧", true}, // 换个话题吧 {"换个话题吧", true}, // 换个话题吧
{"我们聊点别的", true}, // 我们聊点别的 {"我们聊点别的", true}, // 我们聊点别的
{"不聊这个了", true}, // 不聊这个了 {"不聊这个了", true}, // 不聊这个了
{"换个话题,你们产品多少钱", false}, // 换个话题,你们产品多少钱(带了新问题) {"换个话题,你们产品多少钱", false}, // 换个话题,你们产品多少钱(带了新问题)
{"今天星期几", false}, // 今天星期几 {"今天星期几", false}, // 今天星期几
} }
for _, c := range cases { for _, c := range cases {
if got := isPureTopicSwitchMessage(c.content); got != c.want { if got := isPureTopicSwitchMessage(c.content); got != c.want {
@@ -2685,6 +2784,47 @@ func TestFastAutoReplyDefaults(t *testing.T) {
} }
} }
func TestReplyExternalOnlyIgnoresInternalMessages(t *testing.T) {
cfg := config.NewDefaultAutoReplyConfig()
cfg.Enabled = true
cfg.Identity.ReplyExternalOnly = true
cfg.Identity.InternalUserIDs = []string{"internal-user"}
engine := testAutoReplyEngine(cfg)
sentTexts := 0
restoreSenders := stubAutoReplySenders(nil, func(clientID uint32, conversationID string, content string) error {
sentTexts++
return nil
})
defer restoreSenders()
engine.processJob(AutoReplyJob{
ClientID: 7,
RawData: map[string]interface{}{
"type": 11041,
"data": map[string]interface{}{
"conversation_id": "S:robot-user_internal-user",
"sender": "internal-user",
"receiver": "robot-user",
"sender_name": "Internal",
"content": "设备怎么调试",
"server_id": "server-internal-external-only",
},
},
ReceivedAt: time.Unix(1779434669, 0),
})
if sentTexts != 0 {
t.Fatalf("expected internal message to be ignored without reply, sentTexts=%d", sentTexts)
}
if engine.status.TodayReplied != 0 || engine.status.TodayHandoff != 0 || engine.status.TodayIgnored != 1 {
t.Fatalf("expected ignored-only status, replied=%d handoff=%d ignored=%d", engine.status.TodayReplied, engine.status.TodayHandoff, engine.status.TodayIgnored)
}
if len(engine.records) != 1 || engine.records[0].Reason != "internal_ignored_external_only" {
t.Fatalf("expected internal_ignored_external_only record, got %#v", engine.records)
}
}
func TestApplyDefaultsAddsIdentityLabelsAndCustomerNotice(t *testing.T) { func TestApplyDefaultsAddsIdentityLabelsAndCustomerNotice(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
AutoReplyConfig: config.AutoReplyConfig{ AutoReplyConfig: config.AutoReplyConfig{
@@ -2830,7 +2970,7 @@ func TestKnowledgeScopedLowScoreDoesNotUseGeneralReply(t *testing.T) {
} }
} }
func TestMaterialMatchShortCircuitsAIReply(t *testing.T) { func TestExplicitMaterialRequestShortCircuitsAIReply(t *testing.T) {
restoreClients := setTestIdentifiedClients(t, map[uint32]string{7: "robot-user"}) restoreClients := setTestIdentifiedClients(t, map[uint32]string{7: "robot-user"})
defer restoreClients() defer restoreClients()
@@ -2906,15 +3046,18 @@ func TestMaterialMatchShortCircuitsAIReply(t *testing.T) {
"sender": "customer-user", "sender": "customer-user",
"receiver": "robot-user", "receiver": "robot-user",
"sender_name": "Customer", "sender_name": "Customer",
"content": "show cat", "content": "请发 cat 图片",
"server_id": "server-material-short-circuit", "server_id": "server-material-short-circuit",
}, },
}, },
ReceivedAt: time.Now(), ReceivedAt: time.Now(),
}) })
if len(sentTexts) != 1 || sentTexts[0] != "cat image sent" { if len(sentTexts) != 2 ||
t.Fatalf("expected only material caption text, got %#v", sentTexts) !strings.Contains(sentTexts[0], "我也把相关排查说明整理给您") ||
!strings.Contains(sentTexts[0], "cat product knowledge") ||
sentTexts[1] != "cat image sent" {
t.Fatalf("expected tutorial text followed by material caption, got %#v", sentTexts)
} }
if len(sentMaterials) != 1 || sentMaterials[0] != "image:"+materialPath { if len(sentMaterials) != 1 || sentMaterials[0] != "image:"+materialPath {
t.Fatalf("expected one material send, got %#v", sentMaterials) t.Fatalf("expected one material send, got %#v", sentMaterials)
@@ -2924,6 +3067,112 @@ func TestMaterialMatchShortCircuitsAIReply(t *testing.T) {
} }
} }
func TestMaterialSendDeduplicatesSameConversation(t *testing.T) {
restoreClients := setTestIdentifiedClients(t, map[uint32]string{7: "robot-user"})
defer restoreClients()
dir := t.TempDir()
materialPath := filepath.Join(dir, "alarm3.mp4")
if err := os.WriteFile(materialPath, []byte("video"), 0644); err != nil {
t.Fatalf("write material: %v", err)
}
indexPath := filepath.Join(dir, "materials.json")
materials := autoReplyMaterialsFile{Materials: []AutoReplyMaterial{{
ID: "alarm3-video",
Title: "立式机编码器报警3排查视频",
Keywords: []string{"立式机编码器报警3"},
MaterialType: "video",
Path: "alarm3.mp4",
Caption: "报警3排查视频",
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.Enabled = true
cfg.Materials.Directory = dir
cfg.Materials.IndexPath = indexPath
cfg.Retrieval.RetrievalMode = retrievalModeKeywordOnly
cfg.Knowledge.MinScore = 0.1
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("unexpected AI path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"先检查编码器信号线和报警编号。"}}]}`))
}))
defer server.Close()
cfg.AI.BaseURL = server.URL + "/v1"
cfg.AI.Model = "qwen-turbo"
engine := testAutoReplyEngine(cfg)
engine.index = &KnowledgeIndex{Chunks: []KnowledgeChunk{{
ID: "knowledge-alarm3",
Source: "alarm3.md",
Title: "立式机编码器报警3",
Content: "请先检查编码器信号线、端子和控制屏报警编号。",
UpdatedAt: time.Now().Unix(),
Score: 1,
}}}
var sentTexts []string
var sentMaterials []string
oldTextSender := sendAutoReplyTextSender
oldMaterialSender := sendAutoReplyMaterialSender
oldLookupRequester := sendIdentityLookupRequester
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
sentTexts = append(sentTexts, content)
return nil
}
sendAutoReplyMaterialSender = func(clientID uint32, conversationID string, typ string, path string) error {
sentMaterials = append(sentMaterials, typ+":"+path)
return nil
}
sendIdentityLookupRequester = func(clientID uint32, requestType int, query string) error {
return nil
}
t.Cleanup(func() {
sendAutoReplyTextSender = oldTextSender
sendAutoReplyMaterialSender = oldMaterialSender
sendIdentityLookupRequester = oldLookupRequester
})
for i, serverID := range []string{"server-material-1", "server-material-2"} {
engine.processJob(AutoReplyJob{
ClientID: 7,
ForceNoCooldown: i > 0,
RawData: map[string]interface{}{
"type": 11041,
"data": map[string]interface{}{
"conversation_id": "S:robot-user_customer-user",
"sender": "customer-user",
"receiver": "robot-user",
"sender_name": "Customer",
"content": "把立式机编码器报警3排查视频发我",
"server_id": serverID,
},
},
ReceivedAt: time.Now().Add(time.Duration(i) * time.Second),
})
}
if len(sentMaterials) != 1 || sentMaterials[0] != "video:"+materialPath {
t.Fatalf("expected one material send after duplicate request, got %#v", sentMaterials)
}
reasons := map[string]int{}
for _, record := range engine.records {
reasons[record.Reason]++
}
if len(engine.records) != 2 || reasons["materials_replied"] != 1 || reasons["ok"] != 1 {
t.Fatalf("expected second duplicate to fall through to AI reply, got records=%#v texts=%#v", engine.records, sentTexts)
}
}
func TestMeetingKnowledgeSearchFindsSalesButNotResearch(t *testing.T) { func TestMeetingKnowledgeSearchFindsSalesButNotResearch(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := writeTestMeetingWorkbookAt(t, dir) path := writeTestMeetingWorkbookAt(t, dir)

View File

@@ -11,7 +11,9 @@
{ {
"data": { "data": {
"conversation_id": "{{params.conversationId}}", "conversation_id": "{{params.conversationId}}",
"file": "{{params.path}}" "file": "{{params.path}}",
"fileName": "{{params.fileName}}",
"file_name": "{{params.fileName}}"
}, },
"type": 11031 "type": 11031
} }

View File

@@ -180,7 +180,12 @@ if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
if ($pdftoppm) { if ($pdftoppm) {
Write-Host "==> Copying PDF renderer" Write-Host "==> Copying PDF renderer"
Copy-RequiredFile -Source $pdftoppm.Source -Destination (Join-Path $binDir "tools\pdf\pdftoppm.exe") $pdfToolDir = Join-Path $binDir "tools\pdf"
Copy-RequiredFile -Source $pdftoppm.Source -Destination (Join-Path $pdfToolDir "pdftoppm.exe")
$pdftoppmDir = Split-Path -Parent $pdftoppm.Source
Get-ChildItem -LiteralPath $pdftoppmDir -Filter "*.dll" -File | ForEach-Object {
Copy-RequiredFile -Source $_.FullName -Destination (Join-Path $pdfToolDir $_.Name)
}
} else { } else {
Write-Warning "pdftoppm.exe not found; scanned PDF OCR fallback will be unavailable in this build." Write-Warning "pdftoppm.exe not found; scanned PDF OCR fallback will be unavailable in this build."
} }
@@ -228,7 +233,7 @@ if ($silkBuilt -and (Test-Path -LiteralPath $silkDecoderOut)) {
Write-Warning "silkdecode.exe not bundled (gcc unavailable); voice transcoding disabled in this installer." Write-Warning "silkdecode.exe not bundled (gcc unavailable); voice transcoding disabled in this installer."
} }
if (Test-Path -LiteralPath (Join-Path $binDir "tools\pdf\pdftoppm.exe")) { if (Test-Path -LiteralPath (Join-Path $binDir "tools\pdf\pdftoppm.exe")) {
Copy-RequiredFile -Source (Join-Path $binDir "tools\pdf\pdftoppm.exe") -Destination (Join-Path $runtimeDir "tools\pdf\pdftoppm.exe") Copy-Item -LiteralPath (Join-Path $binDir "tools\pdf") -Destination (Join-Path $runtimeDir "tools") -Recurse -Force
} }
Copy-RequiredFile -Source (Join-Path $binDir "Helper_4.1.33.6009.dll") -Destination (Join-Path $runtimeDir "Helper_4.1.33.6009.dll") Copy-RequiredFile -Source (Join-Path $binDir "Helper_4.1.33.6009.dll") -Destination (Join-Path $runtimeDir "Helper_4.1.33.6009.dll")
Copy-RequiredFile -Source (Join-Path $binDir "Loader_4.1.33.6009.dll") -Destination (Join-Path $runtimeDir "Loader_4.1.33.6009.dll") Copy-RequiredFile -Source (Join-Path $binDir "Loader_4.1.33.6009.dll") -Destination (Join-Path $runtimeDir "Loader_4.1.33.6009.dll")

View File

@@ -0,0 +1,273 @@
# yuanzhipeng 提交内容梳理
本文基于仓库 `qiweimanager-master-git` 中的 Git 历史整理,统计范围为:
```powershell
git log --all --author=yuanzhipeng
```
作者信息:`yuanzhipeng <2501363769@qq.com>`
## 1. 分支与提交概览
当前仓库里 `yuanzhipeng` 共有 4 个提交:
| 提交 | 日期 | 所在分支 | 标题 |
| --- | --- | --- | --- |
| `a926ee6` | 2026-06-25 | `main`, `origin/main`, `origin/HEAD` | `chore(build): 更新.gitignore配置和清理Wails临时文件` |
| `1517be2` | 2026-06-26 | `origin/develop` 历史 | `feat(auto-reply): 接入万川平台模型配置 + 各模型独立网关回退` |
| `849090a` | 2026-06-26 | `origin/develop` 历史 | `feat(auto-reply): 优化自动回复逻辑和知识库功能` |
| `1ca66dc` | 2026-06-26 | `origin/develop` | `docs(batch): 更新批处理脚本注释` |
需要注意:当前 `main/origin/main` 只包含 `a926ee6`;后续 3 个功能提交目前在 `origin/develop` 上。
`a926ee6^..1ca66dc` 汇总,整体变更约为:
- 51 个文件变化
- 2922 行新增
- 1299 行删除
- 删除旧备份文件 `helper/auto_reply_ai.go.bak`
- 删除 Wails 构建生成文件 `build/windows/installer/wails_tools.nsh`
## 2. 主要完善内容
### 2.1 万川平台模型配置接入
相关提交:`1517be2`
新增了万川平台配置能力,让自动回复模块可以从万川平台拉取模型配置并自动回填到本地 AI 配置。
主要内容:
- 新增后端接口文件 `wanchuan_proxy.go`
- `WanchuanLogin`:登录万川平台并获取 token。
- `WanchuanGetModel`:按模型 code 获取模型配置。
- `GetPlatformConfig` / `SavePlatformConfig`:读取和保存平台账号配置。
- 日志中对密码、token、apiKey 做脱敏处理。
- 配置结构新增 `PlatformConfig`,用于保存万川平台地址、账号、密码。
- 前端 `AutoReply.vue` 新增“万川平台”配置卡片:
- 填写平台地址、账号、密码。
- 一键“登录并获取模型”。
- 自动拉取 chat、vision、embedding、rerank、voice 等用途模型。
- 将模型名、网关地址、API Key 回填到自动回复配置。
- 前端 Wails 绑定新增:
- `GetPlatformConfig`
- `SavePlatformConfig`
- `WanchuanLogin`
- `WanchuanGetModel`
### 2.2 各类模型支持独立网关和独立 Key
相关提交:`1517be2`
完善了自动回复中不同模型的配置拆分,避免所有能力都被迫共用同一个文本模型网关。
主要内容:
- `RetrievalConfig` 新增:
- `embeddingBaseUrl`
- `embeddingApiKey`
- `rerankBaseUrl`
- `rerankApiKey`
- Embedding 和 Rerank 调用优先使用各自独立配置,未配置时才回退到主 AI 网关。
- 非 DashScope 网关下,视觉模型不再强行写死为 DashScope 的默认视觉模型。
- 万川统一网关场景下,视觉模型可动态复用聊天模型;如果平台提供独立 vision 配置,则使用独立 vision 网关。
- 当 embedding 模型或维度变化时,会识别旧向量索引已经属于不同向量空间,并提示或触发重建,避免旧索引造成检索异常。
### 2.3 自动回复上下文逻辑优化
相关提交:`849090a`
优化了客户追问、换话题和上下文拼接逻辑,减少模型被历史对话带偏的问题。
主要内容:
- 新增 `contextPromptForQuestion`
- 只有“它多少钱”“刚才那个再说说”“继续”等指代型追问才注入历史上下文。
- “今天星期几”“你们有什么产品”等自包含问题不再携带上一轮话题。
- 新增换话题识别:
- 如“换个话题吧”“聊点别的”“不聊这个了”会直接回复引导语。
- 如果“换个话题,你们产品多少钱”这类句子带了新问题,则继续正常处理新问题。
- Prompt 中强调上下文只用于理解称呼和承接,不应主动延续旧话题。
- 默认回复详细度从 `detailed` 调整为 `medium`,避免自动回复过长、太像模板。
### 2.4 知识库索引与向量检索增强
相关提交:`849090a`, `1517be2`
修复和增强了知识库扫描、向量索引、重排调用等流程。
主要内容:
- 知识库重建从只扫描根目录改为递归扫描子目录。
- 适配 `01_产品/数控机床/xxx.md``03_售后/故障排查/xxx.md` 这类分类目录。
- 重建知识库后,前端会显示扫描文件数、分片数、失败文件数。
- 如果 0 个文件被扫描到,会给出明显提示。
- 向量索引加载时检测模型名和维度是否与当前配置一致。
- 不一致时清空陈旧向量,回退关键词检索,并提示重建索引。
- Embedding/Rerank 请求支持独立 Base URL 和 API Key。
### 2.5 素材库扫描、匹配和开场白生成
相关提交:`a926ee6`, `849090a`
完善了素材库能力,尤其是目录化素材、误匹配控制和 AI 自动生成素材发送话术。
主要内容:
- 新增大量示例素材到 `config/materials`,包括:
- 产品图片
- 宣传海报
- 工作流图
- 教程截图
- 案例展示
- 联系方式卡片
- 素材扫描从根目录扫描改为递归扫描子目录。
- 素材路径保存为相对路径,并统一使用 `/` 分隔。
- 子目录名也会加入关键词,方便按分类命中素材。
- 新增 `CaptionSource` 字段,区分素材话术来源:
- `manual`:人工维护,重新同步时不覆盖。
- `ai`AI 生成,已有时不重复生成。
- 空值:历史数据或默认话术,可按需生成。
- 新增 `helper/auto_reply_material_caption.go`
- 图片使用视觉模型生成一句自然开场白。
- 视频按标题生成。
- 文档尝试抽取开头内容后概括。
- 生成结果会去引号、压缩换行、过滤 `NO_ANSWER`、限制长度。
- 素材匹配新增“强信号”门槛。
- 只有整词关键词、问句模板、完整标题或文件名命中才会发送素材。
- 仅靠 2-gram 模糊片段命中不会触发发送,减少误发资料。
### 2.6 媒体消息诊断和类型识别修复
相关提交:`849090a`
增强了企业微信媒体消息回调的识别和诊断能力。
主要内容:
- 修正 `rawTypeFromEvent` 对企业微信事件类型的判断。
- 优先使用 DLL 真实 `event` 字段。
- `content_type` 只作为模拟事件或旧版本 DLL 的 fallback。
- 避免文本消息被误判成图片,导致错误触发图片识别。
- 新增媒体字段为空时的诊断日志。
- 仅在图片、视频、链接等媒体消息且 URL、本地路径、FileID 全空时记录。
- 日志只记录媒体相关字段,避免泄露聊天内容。
### 2.7 构建、开发和打包流程完善
相关提交:`a926ee6`, `1ca66dc`
对 Windows/Wails 的开发和打包流程做了明显增强。
主要内容:
- `.gitignore` 增加构建输出、绿色免安装包、Wails 临时文件、WebView2 安装包等忽略规则。
- 删除 `build/windows/installer/wails_tools.nsh` 这类 Wails 自动生成文件,避免把临时产物提交进仓库。
- 新增 `scripts/dev.ps1`
- 自动定位 Go/Wails。
- 自动安装前端依赖和 Go 模块。
- 编译 32 位 GUI 子系统的 `helper.exe`
- 同步 DLL、`eventdata``requestdata` 到运行目录。
- 启动 `wails dev`
- 新增 `scripts/build.ps1`
- 构建 helper、silkdecode、主程序。
- 输出带时间戳的绿色免安装版目录。
- 写入默认配置文件和空素材索引。
- 改进 `scripts/package-windows.ps1`
- 自动探测 NSIS 标准安装路径。
- gcc 缺失时跳过 silkdecode而不是直接失败。
- 前端构建和 Wails NSIS 构建改为按退出码判断。
- 安装包文件名增加时间戳。
- 新增 `install-nsis.ps1`
- 新增根目录批处理:
- `启动开发.bat`
- `打包.bat`
- `1ca66dc` 对这两个 `.bat` 的注释做了英文化和简化。
### 2.8 主程序退出稳定性修复
相关提交:`a926ee6`
修复 Windows 下关闭主程序时可能因 helper 进程处理方式触发崩溃的问题。
主要内容:
- `main.go` 中 Windows 平台优先使用 Windows API 终止 helper。
- 终止后直接返回,不再继续走通用 `Signal/Kill` 分支。
- 避免 Go 1.25+ 中手工构造的 `os.Process` 因句柄模式无效触发 panic。
### 2.9 测试覆盖增加
相关提交:`1517be2`, `849090a`
补充了多个回归测试,主要覆盖自动回复和配置默认值。
新增或扩展的测试点包括:
- 非 DashScope 网关下视觉模型跟随聊天模型。
- DashScope 仍保留默认视觉模型兜底。
- 知识库递归扫描子目录。
- 素材模糊弱命中不应误发。
- 素材开场白生成、清洗、保留人工话术。
- 自动回复上下文只在指代追问时使用。
- 换话题句子的识别。
- embedding 索引模型/维度不一致时判定为陈旧。
- 默认回复详细度变更为 `medium`
## 3. 每个提交的简要说明
### `a926ee6`:构建体系、素材与基础修复
这个提交表面标题是 `.gitignore` 和 Wails 临时文件清理,但实际内容较多:
- 清理 Wails 自动生成文件。
- 新增万川平台对接文档。
- 新增大量素材图片。
- 新增开发/打包 PowerShell 脚本和 `.bat` 入口。
- 优化 Windows 打包脚本。
- 修复主程序退出时 helper 进程处理可能导致崩溃的问题。
### `1517be2`:万川平台模型配置接入
这是核心功能提交之一:
- 增加平台账号配置保存。
- 增加登录万川平台和拉取模型配置的后端接口。
- 前端新增万川平台配置区。
- 自动回填 chat、vision、embedding、rerank、voice 模型。
- Embedding 和 Rerank 支持独立网关。
- 删除旧的 `helper/auto_reply_ai.go.bak` 备份文件。
### `849090a`:自动回复、知识库和素材库优化
这是自动回复体验提升最集中的提交:
- 上下文只在指代追问时注入。
- 换话题句子直接回复引导语。
- 知识库递归扫描子目录。
- 素材库递归扫描子目录。
- 素材匹配增加强信号门槛,减少误发。
- 新增 AI 生成素材开场白。
- 调整回复详细度默认值和 prompt 策略。
- 增加媒体消息字段诊断日志。
### `1ca66dc`:批处理脚本注释更新
这个提交只改了两个 `.bat` 文件的注释:
- `启动开发.bat`
- `打包.bat`
功能逻辑没有明显变化,主要是说明文字调整。
## 4. 总结
整体来看,`yuanzhipeng` 的提交主要完善了 4 条主线:
1. **万川平台接入**:从平台登录、拉模型、保存配置到前端一键回填,形成了完整闭环。
2. **自动回复质量提升**:处理追问、换话题、回复长度、知识库完整性和媒体消息诊断,减少答非所问和误判。
3. **知识库/素材库能力增强**:支持目录化管理、递归扫描、向量索引一致性检查、素材自动开场白和误发控制。
4. **开发与交付流程完善**:新增一键开发/打包脚本,优化 NSIS、Wails、helper、DLL、运行资源同步降低 Windows 环境下的构建和运行成本。
如果按“是否已进主分支”看,目前只有 `a926ee6``main`;万川接入和自动回复深度优化主要还在 `origin/develop`

View File

@@ -0,0 +1,169 @@
# 本地改动说明(对比 origin/develop
生成时间2026-06-29
本地分支:`develop`
对比基准:`origin/develop`
远程仓库:`https://git.lzwcai.com/ly1213/qiweimanager-master.git`
> 说明:本文只梳理当前工作区相对 Git 远程基线的本地改动,未提交 Git也未改动远程仓库。
## 一、改动概览
本次本地改动主要围绕自动客服、素材发送、知识库重建提示和安装包资源打包四块:
1. 新增“只回复外部客户”开关,开启后企业内部成员消息会被自动忽略,不再触发 AI 回复或转人工。
2. 优化素材发送逻辑,支持按素材子文件夹命中问题场景,显式要求“全部”时才发送该匹配文件夹内全部素材。
3. 优化素材话术和发送顺序,统一使用“您”,并在发送图片/视频/动图后补充描述;发送文件时携带文件名,便于企微侧展示。
4. 命中素材时会把知识库中相关文字说明整理后一起发送,更接近真实客服“先说明、再发资料/或发完资料补描述”的场景。
5. 优化知识库重建错误提示:重建成功后清除旧的知识库错误;向量索引失败时保留明确错误和扫描统计。
6. 优化 Windows 安装包打包,把 `tools` 目录、PDF 渲染依赖 DLL、语音转码工具等运行资源纳入安装包。
## 二、功能改动明细
### 1. 只回复外部客户
- 配置结构新增 `identity.replyExternalOnly`,默认值为 `false`,保持原有行为不变。
- 前端“监听策略”区域新增开关:`只回复外部客户,忽略企业内部成员`
- 后端处理消息时,在身份识别完成后判断:
- 开关关闭:按原逻辑处理内部/外部/未知身份。
- 开关开启且识别为内部成员:直接忽略消息,记录原因为 `internal_ignored_external_only`
涉及文件:
- `config/types.go`
- `frontend/src/components/AutoReply.vue`
- `frontend/src/main.js`
- `helper/auto_reply.go`
- `helper/auto_reply_test.go`
### 2. 素材按文件夹/场景匹配发送
- 去掉了原先“全部资料”这类泛化请求直接短路的粗粒度逻辑,改为更细的判断:
- 用户只说“全部资料/全部文件”,但没有明确具体场景、文件夹或素材关键词时,不自动群发,仍走澄清/普通回复,避免误发大量资料。
- 用户带有明确场景或文件夹关键词例如“0505尾部切刀的资料全部发我”会发送匹配文件夹内的全部素材。
- 用户明确要求某类素材,例如“全部视频/全部图片”,会按类型收集对应素材。
- “资料/文件”不再默认只理解成普通文件;只有出现 `文档、表格、手册、说明书、ppt、pdf、doc、docx、xls、xlsx` 等明确文档词时,才限制为普通文件类型。
涉及文件:
- `helper/auto_reply_materials.go`
- `helper/auto_reply_test.go`
### 3. 素材发送话术与顺序
- 默认话术从“发你”改为“发给您”,包括图片、视频、动图、文件和组合素材话术。
- AI 生成素材开场白的 prompt 增加要求:必须使用“您”。
- 命中素材后,会先从知识库命中片段中整理最多 3 条文字说明,作为“相关排查说明”发送给客户。
- 发送顺序调整:
- 普通文件:先发说明话术,再发文件。
- 图片/视频/动图:先发素材,再发描述文字,使描述更像跟在图片或视频下面。
- 示例素材索引里的 caption 也同步改成“发给您”。
涉及文件:
- `helper/auto_reply_material_caption.go`
- `helper/auto_reply_materials.go`
- `config/materials/materials.json`
- `helper/auto_reply_test.go`
### 4. 文件消息展示文件名
- 普通文件消息 `type=11031` 发送时,额外传入文件名字段:
- `fileName`
- `file_name`
- 文件名从实际路径 `filepath.Base(path)` 提取。
- `requestdata/sendVWorkFileMessage.json` 同步增加文件名占位符,便于底层请求模板使用。
涉及文件:
- `helper/auto_reply_materials.go`
- `requestdata/sendVWorkFileMessage.json`
### 5. 知识库重建提示与错误状态
- 知识库重建接口在失败时也返回已扫描到的统计信息,包括耗时、文件数、分片数和失败文件列表,方便前端展示。
- 关键词模式下不强制重建向量索引。
- 混合/向量模式下,如果 embedding 重建失败,会返回已扫描的知识库索引并保留知识库错误。
- 知识库重建成功后,会清除旧的知识库错误,避免前端一直显示历史失败信息。
- 前端 section alert 支持 `success / warn / error` 三种类型,成功提示会自动清掉对应区域旧错误。
涉及文件:
- `helper/auto_reply_http.go`
- `helper/auto_reply_knowledge.go`
- `helper/auto_reply_status.go`
- `helper/auto_reply_knowledge_test.go`
- `frontend/src/components/AutoReply.vue`
### 6. 安装包运行资源打包
- NSIS 安装脚本新增复制 `$INSTDIR\tools`,安装后会带上运行工具目录。
- 打包脚本复制 `pdftoppm.exe` 时,会同步复制同目录 DLL避免部署环境缺 DLL 导致 PDF 解析不可用。
- 打包阶段将 `tools\pdf` 整个目录同步进 NSIS runtime。
- 当前本地已生成的新安装包中包含语音转码工具 `silkdecode.exe`
涉及文件:
- `build/windows/installer/project.nsi`
- `scripts/package-windows.ps1`
最新本地安装包:
`build/bin/qiweimanager-amd64-installer_20260629_145726.exe`
## 三、测试与验证情况
本地新增/调整的自动化测试覆盖了以下场景:
- 开启“只回复外部客户”后,内部成员消息不会触发回复,并记录忽略原因。
- 普通场景素材请求遵守 `maxPerReply` 限制。
- 明确要求某个匹配文件夹“全部资料”时,会发送该文件夹下全部素材。
- 泛化“全部资料都发我”不会直接群发。
- 素材默认话术统一改为“发给您”。
- 命中素材时,会把知识库相关说明与素材一起发送。
- 知识库重建成功后清除旧错误embedding 失败时保留知识库错误并返回扫描结果。
建议开发同事复核时运行:
```powershell
go test ./helper ./config
npm.cmd --prefix frontend run build
```
如果需要验证安装包资源,请重点检查安装目录下是否存在:
```text
tools/audio/silkdecode.exe
tools/pdf/pdftoppm.exe
```
## 四、注意事项
- 当前工作区还有 Wails 构建生成文件的差异痕迹,主要是 `frontend/wailsjs/runtime/runtime.d.ts``frontend/wailsjs/runtime/runtime.js`。这些看起来是 Wails CLI 版本差异造成的生成文件变化,不属于本次核心业务逻辑改动,建议提交前由开发同事确认是否需要保留。
- `go.mod`、部分 `frontend/wailsjs/go/*` 文件在 `git status` 中可能因换行符显示为修改,但当前 `git diff --name-only` 未显示实际内容差异。
- 本文档本身是新增的本地说明文件,不属于产品运行逻辑。
- 另有一份未跟踪文档 `yuanzhipeng提交内容梳理.md`,是之前梳理 yuanzhipeng 提交内容生成的说明文档。
## 五、当前实际内容差异文件
根据 `git diff --name-only`,当前有实际内容差异的文件如下:
```text
build/windows/installer/project.nsi
config/materials/materials.json
config/types.go
frontend/src/components/AutoReply.vue
frontend/src/main.js
frontend/wailsjs/runtime/runtime.d.ts
frontend/wailsjs/runtime/runtime.js
helper/auto_reply.go
helper/auto_reply_http.go
helper/auto_reply_knowledge.go
helper/auto_reply_knowledge_test.go
helper/auto_reply_material_caption.go
helper/auto_reply_materials.go
helper/auto_reply_status.go
helper/auto_reply_test.go
requestdata/sendVWorkFileMessage.json
scripts/package-windows.ps1
```