feat: update auto reply and packaging
This commit is contained in:
@@ -120,6 +120,9 @@ Section
|
||||
File "runtime\Helper_*.dll"
|
||||
File "runtime\Loader_*.dll"
|
||||
|
||||
SetOutPath "$INSTDIR\tools"
|
||||
File /r "runtime\tools\*.*"
|
||||
|
||||
SetOutPath "$INSTDIR\requestdata"
|
||||
File /r "runtime\requestdata\*.*"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
],
|
||||
"materialType": "image",
|
||||
"path": "猫猫图片.jpg",
|
||||
"caption": "我把图片发你。",
|
||||
"caption": "我把图片发给您。",
|
||||
"priority": 3,
|
||||
"enabled": true
|
||||
},
|
||||
@@ -39,7 +39,7 @@
|
||||
],
|
||||
"materialType": "file",
|
||||
"path": "售后问题库_2026-05-30_1629.xlsx",
|
||||
"caption": "我把售后问题表发你。",
|
||||
"caption": "我把售后问题表发给您。",
|
||||
"priority": 2,
|
||||
"enabled": true
|
||||
},
|
||||
@@ -61,7 +61,7 @@
|
||||
],
|
||||
"materialType": "file",
|
||||
"path": "方案模板.docx",
|
||||
"caption": "我把方案模板发你。",
|
||||
"caption": "我把方案模板发给您。",
|
||||
"priority": 2,
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ type HandoffConfig struct {
|
||||
type IdentityConfig struct {
|
||||
UnknownPolicy string `json:"unknownPolicy"`
|
||||
UnknownHandoffPolicy string `json:"unknownHandoffPolicy"`
|
||||
ReplyExternalOnly bool `json:"replyExternalOnly"`
|
||||
RefreshOnStart bool `json:"refreshOnStart"`
|
||||
RefreshIntervalMinutes int `json:"refreshIntervalMinutes"`
|
||||
PageSize int `json:"pageSize"`
|
||||
@@ -272,6 +273,7 @@ func NewDefaultAutoReplyConfig() AutoReplyConfig {
|
||||
Identity: IdentityConfig{
|
||||
UnknownPolicy: "customer",
|
||||
UnknownHandoffPolicy: "hold",
|
||||
ReplyExternalOnly: false,
|
||||
RefreshOnStart: true,
|
||||
RefreshIntervalMinutes: 30,
|
||||
PageSize: 200,
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 class="form-grid">
|
||||
<label class="check-row">
|
||||
@@ -117,6 +117,10 @@
|
||||
<input type="checkbox" v-model="form.listen.ignoreSelfMessage">
|
||||
<span>忽略自己发送的消息</span>
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="form.identity.replyExternalOnly">
|
||||
<span>只回复外部客户,忽略企业内部成员</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>群聊触发</span>
|
||||
<select v-model="form.listen.groupTriggerMode">
|
||||
@@ -169,7 +173,7 @@
|
||||
</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 v-for="alert in sectionAlerts('ai')" :key="alert.text" class="section-alert" :class="alert.type">{{ alert.text }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 万川平台配置卡片 -->
|
||||
@@ -345,7 +349,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 class="form-grid">
|
||||
<label class="wide">
|
||||
@@ -473,7 +477,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 class="form-grid">
|
||||
<label>
|
||||
@@ -549,7 +553,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 class="form-grid">
|
||||
<label>
|
||||
@@ -770,7 +774,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 class="record-table">
|
||||
<div class="record-row header">
|
||||
@@ -1056,46 +1060,72 @@ function classifyStatusErrorScope(text, scope) {
|
||||
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()
|
||||
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) {
|
||||
alerts.push(normalized)
|
||||
} else if (normalized.length > alerts[index].length) {
|
||||
alerts[index] = normalized
|
||||
alerts.push({ text: normalized, type: messageType })
|
||||
} else if (normalized.length > alerts[index].text.length || alerts[index].type !== 'error') {
|
||||
alerts[index] = { text: normalized, type: messageType }
|
||||
}
|
||||
}
|
||||
|
||||
function sectionAlerts(sectionId) {
|
||||
const scope = normalizeSectionScope(sectionId)
|
||||
const alerts = []
|
||||
addUniqueAlert(alerts, scopedMessages.value[scope])
|
||||
const scopedMessage = scopedMessages.value[scope]
|
||||
addUniqueAlert(alerts, scopedMessage?.text || scopedMessage, scopedMessage?.type || 'error')
|
||||
if (scope === 'identity') {
|
||||
addUniqueAlert(alerts, status.value.identityRefreshError)
|
||||
addUniqueAlert(alerts, status.value.internalGroupMemberSyncError)
|
||||
addUniqueAlert(alerts, status.value.identityRefreshError, 'error')
|
||||
addUniqueAlert(alerts, status.value.internalGroupMemberSyncError, 'error')
|
||||
}
|
||||
const lastError = String(status.value.lastError || '').trim()
|
||||
if (lastError && classifyStatusErrorScope(lastError, status.value.lastErrorScope) === scope) {
|
||||
addUniqueAlert(alerts, lastError)
|
||||
addUniqueAlert(alerts, lastError, 'error')
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
function setScopedMessage(scope, text) {
|
||||
function clearScopedMessage(scope) {
|
||||
const target = normalizeSectionScope(scope)
|
||||
const normalized = String(text || '').trim()
|
||||
if (!normalized) return
|
||||
scopedMessages.value = { ...scopedMessages.value, [target]: normalized }
|
||||
if (scopedMessageTimers[target]) {
|
||||
clearTimeout(scopedMessageTimers[target])
|
||||
scopedMessageTimers[target] = null
|
||||
}
|
||||
scopedMessageTimers[target] = setTimeout(() => {
|
||||
if (scopedMessages.value[target] !== normalized) return
|
||||
if (scopedMessages.value[target]) {
|
||||
const next = { ...scopedMessages.value }
|
||||
delete next[target]
|
||||
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
|
||||
return
|
||||
}
|
||||
scopedMessageTimers[target] = setTimeout(() => {
|
||||
const current = scopedMessages.value[target]
|
||||
if (!current || current.text !== normalized || current.type !== messageType) return
|
||||
clearScopedMessage(target)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
@@ -1181,6 +1211,7 @@ function defaultConfig() {
|
||||
identity: {
|
||||
unknownPolicy: 'customer',
|
||||
unknownHandoffPolicy: 'hold',
|
||||
replyExternalOnly: false,
|
||||
refreshOnStart: true,
|
||||
refreshIntervalMinutes: 30,
|
||||
pageSize: 200,
|
||||
@@ -2399,7 +2430,7 @@ async function saveConfig(scope = 'listen', silentSuccess = false) {
|
||||
return false
|
||||
}
|
||||
if (!silentSuccess) {
|
||||
notify('自动客服配置已保存', 'success')
|
||||
notify('自动客服配置已保存', 'success', scope)
|
||||
}
|
||||
await loadStatus()
|
||||
return true
|
||||
@@ -2421,7 +2452,7 @@ async function handleStart() {
|
||||
await SaveAutoReplyConfig(JSON.stringify(form))
|
||||
await SetAutoReplyEnabled(true)
|
||||
await SendWxWorkData('0', JSON.stringify({ type: 10000, data: {} }))
|
||||
notify('自动客服已开启,正在监听当前接管账号', 'success')
|
||||
notify('自动客服已开启,正在监听当前接管账号', 'success', 'listen')
|
||||
await loadStatus()
|
||||
} catch (err) {
|
||||
notify(`开启失败: ${err.message || err}`, 'error', 'listen')
|
||||
@@ -2439,7 +2470,7 @@ async function handleDisable() {
|
||||
normalizeHandoffBeforeSave()
|
||||
await SaveAutoReplyConfig(JSON.stringify(form))
|
||||
await SetAutoReplyEnabled(false)
|
||||
notify('自动客服已关闭', 'success')
|
||||
notify('自动客服已关闭', 'success', 'listen')
|
||||
await loadStatus()
|
||||
} catch (err) {
|
||||
notify(`关闭失败: ${err.message || err}`, 'error', 'listen')
|
||||
@@ -2463,6 +2494,7 @@ async function rebuildKnowledge() {
|
||||
notify('知识库索引已重建,但未扫描到任何知识文件,请确认知识目录和文件格式是否正确。', 'error', 'knowledge')
|
||||
} else {
|
||||
const failedSuffix = failedCount > 0 ? `,${failedCount} 个文件解析失败` : ''
|
||||
if (failedCount === 0) clearScopedMessage('knowledge')
|
||||
notify(`知识库索引已重建:${fileCount} 个文件、${chunkCount} 个分片${failedSuffix}。`, failedCount > 0 ? 'error' : 'success', 'knowledge')
|
||||
}
|
||||
} else {
|
||||
@@ -2574,8 +2606,14 @@ async function testHandoff() {
|
||||
function notify(text, type = 'success', scope = '') {
|
||||
const normalized = String(text || '').trim()
|
||||
if (!normalized) return
|
||||
const target = knownSectionScope(scope)
|
||||
if (target) {
|
||||
if (type !== 'error') clearScopedMessage(target)
|
||||
setScopedMessage(target, normalized, type)
|
||||
return
|
||||
}
|
||||
if (type === 'error') {
|
||||
setScopedMessage(scope || classifyErrorScope(normalized), normalized)
|
||||
setScopedMessage(classifyErrorScope(normalized), normalized, type)
|
||||
return
|
||||
}
|
||||
message.value = normalized
|
||||
@@ -2897,6 +2935,21 @@ button:disabled {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
@@ -3597,6 +3650,24 @@ button:disabled {
|
||||
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 {
|
||||
border-color: rgba(255, 209, 102, 0.34);
|
||||
color: var(--cmd-amber);
|
||||
|
||||
@@ -211,6 +211,7 @@ if (typeof window.go === 'undefined') {
|
||||
identity: {
|
||||
unknownPolicy: 'customer',
|
||||
unknownHandoffPolicy: 'hold',
|
||||
replyExternalOnly: false,
|
||||
refreshOnStart: true,
|
||||
refreshIntervalMinutes: 30,
|
||||
pageSize: 200,
|
||||
|
||||
81
frontend/wailsjs/runtime/runtime.d.ts
vendored
81
frontend/wailsjs/runtime/runtime.d.ts
vendored
@@ -247,84 +247,3 @@ export function CanResolveFilePaths(): boolean;
|
||||
|
||||
// Resolves file paths for an array of files
|
||||
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>;
|
||||
@@ -48,10 +48,6 @@ export function EventsOff(eventName, ...additionalEventNames) {
|
||||
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||
}
|
||||
|
||||
export function EventsOffAll() {
|
||||
return window.runtime.EventsOffAll();
|
||||
}
|
||||
|
||||
export function EventsOnce(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, 1);
|
||||
}
|
||||
@@ -240,59 +236,3 @@ export function CanResolveFilePaths() {
|
||||
export function 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);
|
||||
}
|
||||
@@ -125,6 +125,10 @@ func (e *AutoReplyEngine) processJob(job AutoReplyJob) {
|
||||
if name := e.displayNameForMessage(msg); name != "" {
|
||||
msg.FromNickName = name
|
||||
}
|
||||
if cfg.Identity.ReplyExternalOnly && identity.isInternal() {
|
||||
e.ignoreMessage(msg, "internal_ignored_external_only")
|
||||
return
|
||||
}
|
||||
if identity.Source == identitySourceUnknownAsCustomer {
|
||||
e.noteReason(identitySourceUnknownAsCustomer)
|
||||
if !msg.IsGroup {
|
||||
@@ -269,7 +273,11 @@ func (e *AutoReplyEngine) processJob(job AutoReplyJob) {
|
||||
}
|
||||
e.setLastRetrievalScores(searchResult.KeywordScore, searchResult.VectorScore, searchResult.RerankScore)
|
||||
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())
|
||||
}
|
||||
return
|
||||
|
||||
@@ -54,9 +54,18 @@ func handleAutoReplyRebuildKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
idx, err := getAutoReplyEngine().rebuildKnowledgeIndex()
|
||||
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{}{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
"data": data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,9 +162,18 @@ func (e *AutoReplyEngine) rebuildKnowledgeIndex() (*KnowledgeIndex, error) {
|
||||
return nil, err
|
||||
}
|
||||
e.updateKnowledgeStatus(idx)
|
||||
if requiresEmbeddingIndex(cfg.Retrieval.RetrievalMode) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -206,6 +215,19 @@ func (e *AutoReplyEngine) updateKnowledgeStatus(idx *KnowledgeIndex) {
|
||||
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 {
|
||||
textTokens := tokenizeKnowledgeText(chunk.Title + " " + chunk.Content)
|
||||
if len(textTokens) == 0 {
|
||||
|
||||
@@ -96,6 +96,7 @@ func TestRebuildKnowledgeIndexScansSubdirectories(t *testing.T) {
|
||||
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)
|
||||
|
||||
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) {
|
||||
path := filepath.Join(t.TempDir(), "text.pdf")
|
||||
writeMinimalTextPDF(t, path, "AgentBox PDF content 123")
|
||||
|
||||
@@ -131,7 +131,7 @@ func generateMaterialCaptionByChat(aiCfg config.AIConfig, provider string, userP
|
||||
|
||||
func materialCaptionSystemPrompt() string {
|
||||
return "你是企业微信里的真人客服,现在要把一份资料顺手发给客户。请写一句自然口语的开场白," +
|
||||
"要求:①只有一句话,不超过40字;②像微信里随手发东西时说的话,亲切自然,不要书面腔和客服模板腔" +
|
||||
"要求:①只有一句话,不超过40字;②使用“您”,像微信里顺手发东西时说的话,亲切自然,不要书面腔和客服模板腔" +
|
||||
"(不要用“您好”“为您提供”“请查收”这类);③结合资料内容点出这是什么、对客户有什么用;" +
|
||||
"④不要编造资料里没有的信息;⑤只输出这句话本身,不要加引号、解释或多余标点。"
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const autoReplyMaterialRepeatWindow = 30 * time.Minute
|
||||
|
||||
type AutoReplyMaterial struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -56,9 +59,6 @@ func (e *AutoReplyEngine) matchMaterials(userQuery string, searchContext string,
|
||||
if !cfg.Materials.AutoSendEnabled {
|
||||
return nil
|
||||
}
|
||||
if isBroadAllMaterialRequest(userQuery) {
|
||||
return nil
|
||||
}
|
||||
materials, err := loadAutoReplyMaterials(cfg.Materials.IndexPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
@@ -74,7 +74,23 @@ func (e *AutoReplyEngine) matchMaterials(userQuery string, searchContext string,
|
||||
|
||||
requestedTypes := requestedMaterialTypes(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
|
||||
}
|
||||
queryText := buildMaterialSearchText(userQuery, "", nil, false)
|
||||
@@ -122,6 +138,86 @@ func (e *AutoReplyEngine) collectMaterialMatches(materials []AutoReplyMaterial,
|
||||
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 {
|
||||
limit := maxPerReply
|
||||
if limit <= 0 {
|
||||
@@ -192,6 +288,29 @@ func requestedMaterialTypes(query string) map[string]bool {
|
||||
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 {
|
||||
for _, keyword := range keywords {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
captions := 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 {
|
||||
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")
|
||||
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)
|
||||
sent := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
}
|
||||
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)
|
||||
}
|
||||
e.rememberSentMaterial(msg, match)
|
||||
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.incStatus("replied")
|
||||
@@ -689,6 +815,15 @@ func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoRepl
|
||||
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 {
|
||||
if caption := customMaterialCaptionForSend(material); caption != "" {
|
||||
return caption
|
||||
@@ -696,6 +831,45 @@ func materialCaptionForSend(material AutoReplyMaterial) string {
|
||||
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 {
|
||||
caption := strings.TrimSpace(material.Caption)
|
||||
if caption != "" && !isLegacyGenericMaterialCaption(caption) {
|
||||
@@ -718,19 +892,19 @@ func isLegacyGenericMaterialCaption(caption string) bool {
|
||||
func defaultMaterialCaption(materialType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(materialType)) {
|
||||
case "image":
|
||||
return "我把图片发你。"
|
||||
return "我把图片发给您。"
|
||||
case "video":
|
||||
return "我把视频发你。"
|
||||
return "我把视频发给您。"
|
||||
case "gif":
|
||||
return "我把动图发你。"
|
||||
return "我把动图发给您。"
|
||||
default:
|
||||
return "我把文件发你。"
|
||||
return "我把文件发给您。"
|
||||
}
|
||||
}
|
||||
|
||||
func combinedMaterialCaption(matches []autoReplyMaterialMatch) string {
|
||||
if len(matches) == 0 {
|
||||
return "我把文件发你。"
|
||||
return "我把文件发给您。"
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
labels := make([]string, 0, 4)
|
||||
@@ -755,7 +929,7 @@ func combinedMaterialCaption(matches []autoReplyMaterialMatch) string {
|
||||
if len(labels) == 1 {
|
||||
return defaultMaterialCaption(matches[0].Material.MaterialType)
|
||||
}
|
||||
return "我把" + strings.Join(labels, "和") + "发你。"
|
||||
return "我把" + strings.Join(labels, "和") + "发给您。"
|
||||
}
|
||||
|
||||
func uniqueMaterialStrings(items []string) []string {
|
||||
@@ -805,6 +979,13 @@ func sendAutoReplyMaterialRequest(clientID uint32, conversationID string, materi
|
||||
"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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -121,6 +121,7 @@ type AutoReplyEngine struct {
|
||||
humanPending map[string]*humanAssistPending
|
||||
collaborations map[string]*collaborationSession
|
||||
autoSent map[string]time.Time
|
||||
materialSent map[string]time.Time
|
||||
records []AutoReplyRecord
|
||||
nextRecordID int64
|
||||
status AutoReplyStatus
|
||||
@@ -177,6 +178,7 @@ func initAutoReplyEngine() {
|
||||
humanPending: make(map[string]*humanAssistPending),
|
||||
collaborations: make(map[string]*collaborationSession),
|
||||
autoSent: make(map[string]time.Time),
|
||||
materialSent: make(map[string]time.Time),
|
||||
status: AutoReplyStatus{
|
||||
Enabled: 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 {
|
||||
switch strings.TrimSpace(scope) {
|
||||
case autoReplyErrorScopeListen:
|
||||
|
||||
@@ -31,6 +31,8 @@ func testAutoReplyEngine(cfg config.AutoReplyConfig) *AutoReplyEngine {
|
||||
identityGroups: make(map[int32]map[string]autoReplyGroupOption),
|
||||
contextEntries: make(map[string][]autoReplyContextEntry),
|
||||
collaborations: make(map[string]*collaborationSession),
|
||||
autoSent: make(map[string]time.Time),
|
||||
materialSent: make(map[string]time.Time),
|
||||
status: AutoReplyStatus{
|
||||
ReasonCounts: make(map[string]int),
|
||||
},
|
||||
@@ -313,7 +315,7 @@ func TestSendMaterialsRoutesByMessageClientID(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if textClient != 42 || textConversation != msg.ConversationID || textContent != "安装视频发你。" {
|
||||
@@ -334,11 +336,11 @@ func TestMaterialDefaultCaptionsByType(t *testing.T) {
|
||||
caption string
|
||||
want string
|
||||
}{
|
||||
{name: "image", materialType: "image", want: "我把图片发你。"},
|
||||
{name: "video", materialType: "video", want: "我把视频发你。"},
|
||||
{name: "gif", materialType: "gif", want: "我把动图发你。"},
|
||||
{name: "file", materialType: "file", want: "我把文件发你。"},
|
||||
{name: "legacy generic", materialType: "image", caption: "我把相关资料直接发你。", want: "我把图片发你。"},
|
||||
{name: "image", materialType: "image", want: "我把图片发给您。"},
|
||||
{name: "video", materialType: "video", want: "我把视频发给您。"},
|
||||
{name: "gif", materialType: "gif", want: "我把动图发给您。"},
|
||||
{name: "file", materialType: "file", want: "我把文件发给您。"},
|
||||
{name: "legacy generic", materialType: "image", caption: "我把相关资料直接发你。", want: "我把图片发给您。"},
|
||||
{name: "custom", materialType: "video", caption: "安装视频发你。", want: "安装视频发你。"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -360,7 +362,7 @@ func TestCombinedMaterialCaptionMergesTypes(t *testing.T) {
|
||||
{Material: AutoReplyMaterial{MaterialType: "image"}},
|
||||
{Material: AutoReplyMaterial{MaterialType: "video"}},
|
||||
}
|
||||
if got := combinedMaterialCaption(matches); got != "我把图片和视频发你。" {
|
||||
if got := combinedMaterialCaption(matches); got != "我把图片和视频发给您。" {
|
||||
t.Fatalf("expected merged type caption, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -393,10 +395,10 @@ func TestSendMaterialsUsesTypedDefaultCaption(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if sentText != "我把图片发你。" {
|
||||
if 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) {
|
||||
dir := t.TempDir()
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
fileName := "企业级AI数字员工解决方案宣传手册(无标价版)(1).pptx"
|
||||
@@ -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) {
|
||||
cfg := &config.Config{
|
||||
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"})
|
||||
defer restoreClients()
|
||||
|
||||
@@ -2906,15 +3046,18 @@ func TestMaterialMatchShortCircuitsAIReply(t *testing.T) {
|
||||
"sender": "customer-user",
|
||||
"receiver": "robot-user",
|
||||
"sender_name": "Customer",
|
||||
"content": "show cat",
|
||||
"content": "请发 cat 图片",
|
||||
"server_id": "server-material-short-circuit",
|
||||
},
|
||||
},
|
||||
ReceivedAt: time.Now(),
|
||||
})
|
||||
|
||||
if len(sentTexts) != 1 || sentTexts[0] != "cat image sent" {
|
||||
t.Fatalf("expected only material caption text, got %#v", sentTexts)
|
||||
if len(sentTexts) != 2 ||
|
||||
!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 {
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
path := writeTestMeetingWorkbookAt(t, dir)
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
{
|
||||
"data": {
|
||||
"conversation_id": "{{params.conversationId}}",
|
||||
"file": "{{params.path}}"
|
||||
"file": "{{params.path}}",
|
||||
"fileName": "{{params.fileName}}",
|
||||
"file_name": "{{params.fileName}}"
|
||||
},
|
||||
"type": 11031
|
||||
}
|
||||
|
||||
@@ -180,7 +180,12 @@ if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
|
||||
|
||||
if ($pdftoppm) {
|
||||
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 {
|
||||
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."
|
||||
}
|
||||
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 "Loader_4.1.33.6009.dll") -Destination (Join-Path $runtimeDir "Loader_4.1.33.6009.dll")
|
||||
|
||||
273
yuanzhipeng提交内容梳理.md
Normal file
273
yuanzhipeng提交内容梳理.md
Normal 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`。
|
||||
169
本地改动说明_20260629.md
Normal file
169
本地改动说明_20260629.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user