feat: harden support, localize knowledge and add operator availability [skip ci]

Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package builders
import (
@@ -45,9 +47,7 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo
ClosedBy: item.ClosedBy,
CloseReason: item.CloseReason,
}
if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil {
ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID)
}
ret.CustomerOnline = services.WsService.IsCustomerOnline(item.CustomerID, item.ChannelID)
if item.CurrentAssigneeID > 0 {
if user := services.UserService.Get(item.CurrentAssigneeID); user != nil {
ret.CurrentAssigneeName = user.Nickname

View File

@@ -1,21 +1,23 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/i18nx"
"encoding/json"
"fmt"
)
func BuildKnowledgeBase(item *models.KnowledgeBase) response.KnowledgeBaseResponse {
func BuildKnowledgeBase(item *models.KnowledgeBase, locales ...string) response.KnowledgeBaseResponse {
return response.KnowledgeBaseResponse{
ID: item.ID,
Name: item.Name,
Description: item.Description,
KnowledgeType: item.KnowledgeType,
KnowledgeTypeName: enums.GetKnowledgeBaseTypeLabel(enums.KnowledgeBaseType(item.KnowledgeType)),
KnowledgeTypeName: knowledgeLabel(locales, "type", item.KnowledgeType),
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
DefaultTopK: item.DefaultTopK,
DefaultScoreThreshold: item.DefaultScoreThreshold,
DefaultRerankLimit: item.DefaultRerankLimit,
@@ -24,7 +26,7 @@ func BuildKnowledgeBase(item *models.KnowledgeBase) response.KnowledgeBaseRespon
ChunkMaxTokens: item.ChunkMaxTokens,
ChunkOverlapTokens: item.ChunkOverlapTokens,
AnswerMode: item.AnswerMode,
AnswerModeName: enums.GetKnowledgeAnswerModeLabel(enums.KnowledgeAnswerMode(item.AnswerMode)),
AnswerModeName: knowledgeLabel(locales, "mode", item.AnswerMode),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
@@ -33,18 +35,18 @@ func BuildKnowledgeBase(item *models.KnowledgeBase) response.KnowledgeBaseRespon
}
}
func BuildKnowledgeDocument(item *models.KnowledgeDocument) response.KnowledgeDocumentResponse {
func BuildKnowledgeDocument(item *models.KnowledgeDocument, locales ...string) response.KnowledgeDocumentResponse {
return response.KnowledgeDocumentResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
DirectoryID: item.DirectoryID,
Title: item.Title,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
IndexError: knowledgeIndexError(locales, item.IndexError),
ContentHash: item.ContentHash,
ContentType: item.ContentType,
Content: item.Content,
@@ -55,18 +57,18 @@ func BuildKnowledgeDocument(item *models.KnowledgeDocument) response.KnowledgeDo
}
}
func BuildKnowledgeDocumentList(item *models.KnowledgeDocument) response.KnowledgeDocumentListResponse {
func BuildKnowledgeDocumentList(item *models.KnowledgeDocument, locales ...string) response.KnowledgeDocumentListResponse {
return response.KnowledgeDocumentListResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
DirectoryID: item.DirectoryID,
Title: item.Title,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
IndexError: knowledgeIndexError(locales, item.IndexError),
ContentHash: item.ContentHash,
ContentType: item.ContentType,
CreatedAt: item.CreatedAt,
@@ -76,7 +78,7 @@ func BuildKnowledgeDocumentList(item *models.KnowledgeDocument) response.Knowled
}
}
func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse {
func BuildKnowledgeFAQ(item *models.KnowledgeFAQ, locales ...string) response.KnowledgeFAQResponse {
return response.KnowledgeFAQResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
@@ -85,11 +87,11 @@ func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse
Answer: item.Answer,
SimilarQuestions: parseSimilarQuestions(item.SimilarQuestions),
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
IndexStatus: item.IndexStatus,
IndexStatusName: enums.GetKnowledgeDocumentIndexStatusLabel(item.IndexStatus),
IndexStatusName: knowledgeLabel(locales, "index", item.IndexStatus),
IndexedAt: item.IndexedAt,
IndexError: item.IndexError,
IndexError: knowledgeIndexError(locales, item.IndexError),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
@@ -98,7 +100,7 @@ func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse
}
}
func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.KnowledgeDirectoryResponse {
func BuildKnowledgeDirectory(item *models.KnowledgeDirectory, locales ...string) response.KnowledgeDirectoryResponse {
return response.KnowledgeDirectoryResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
@@ -106,7 +108,7 @@ func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.Knowledge
Name: item.Name,
SortNo: item.SortNo,
Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status),
StatusName: knowledgeLabel(locales, "status", item.Status),
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
@@ -116,14 +118,14 @@ func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.Knowledge
}
}
func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.KnowledgeRetrieveLogResponse {
func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog, locales ...string) response.KnowledgeRetrieveLogResponse {
return response.KnowledgeRetrieveLogResponse{
ID: item.ID,
KnowledgeBaseID: item.KnowledgeBaseID,
Channel: item.Channel,
ChannelName: enums.GetKnowledgeRetrieveChannelLabel(enums.KnowledgeRetrieveChannel(item.Channel)),
ChannelName: knowledgeLabel(locales, "channel", item.Channel),
Scene: item.Scene,
SceneName: enums.GetKnowledgeRetrieveSceneLabel(enums.KnowledgeRetrieveScene(item.Scene)),
SceneName: knowledgeLabel(locales, "scene", item.Scene),
SessionID: item.SessionID,
ConversationID: item.ConversationID,
RequestID: item.RequestID,
@@ -131,7 +133,7 @@ func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.Knowl
RewriteQuestion: item.RewriteQuestion,
Answer: item.Answer,
AnswerStatus: item.AnswerStatus,
AnswerStatusName: enums.GetKnowledgeAnswerStatusLabel(enums.KnowledgeAnswerStatus(item.AnswerStatus)),
AnswerStatusName: knowledgeLabel(locales, "answer", item.AnswerStatus),
HitCount: item.HitCount,
TopScore: item.TopScore,
ChunkProvider: item.ChunkProvider,
@@ -153,7 +155,7 @@ func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.Knowl
}
}
func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit) response.KnowledgeRetrieveHitResponse {
func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit, locales ...string) response.KnowledgeRetrieveHitResponse {
return response.KnowledgeRetrieveHitResponse{
ID: item.ID,
RetrieveLogID: item.RetrieveLogID,
@@ -167,7 +169,7 @@ func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit) respon
Title: item.Title,
SectionPath: item.SectionPath,
ChunkType: item.ChunkType,
ChunkTypeName: enums.GetKnowledgeChunkTypeLabel(enums.KnowledgeChunkType(item.ChunkType)),
ChunkTypeName: knowledgeLabel(locales, "chunk", item.ChunkType),
Provider: item.Provider,
RankNo: item.RankNo,
Score: item.Score,
@@ -189,3 +191,41 @@ func parseSimilarQuestions(raw string) []string {
}
return items
}
var knowledgeLabelCodes = map[string]map[string]bool{
"type": {"document": true, "faq": true},
"status": {"0": true, "1": true, "2": true},
"index": {"pending": true, "indexed": true, "failed": true},
"mode": {"1": true, "2": true},
"channel": {"im": true, "agent_assist": true, "api": true, "debug": true},
"scene": {"first_response": true, "assist": true, "qa": true},
"answer": {"1": true, "2": true, "3": true, "4": true},
"chunk": {"text": true, "faq": true, "table": true, "code": true},
}
// Labels are application-owned; titles, content, snippets and historical answers are not.
func knowledgeLabel(locales []string, group string, value any) string {
code := fmt.Sprint(value)
key := "knowledge.labels.unknown"
if knowledgeLabelCodes[group][code] {
key = "knowledge.labels." + group + "." + code
}
return i18nx.Getf(knowledgeLocale(locales), key)
}
func knowledgeLocale(locales []string) string {
if len(locales) > 0 {
return i18nx.NormalizeLocale(locales[0])
}
return i18nx.DefaultLocale
}
// Legacy index_error values can contain SQL, credentials or provider response bodies.
// Keep those values out of the public DTO; this does not rewrite persisted diagnostics.
func knowledgeIndexError(locales []string, stored string) string {
if stored == "" {
return ""
}
return i18nx.Getf(knowledgeLocale(locales), "error.knowledge.indexFailed")
}

View File

@@ -0,0 +1,47 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/i18nx"
"strings"
"testing"
)
func TestKnowledgeBuildersLocalizeLabelsWithoutChangingContent(t *testing.T) {
for _, locale := range []string{"en-US", "ru-RU", "zh-CN"} {
t.Run(locale, func(t *testing.T) {
kb := BuildKnowledgeBase(&models.KnowledgeBase{Name: "用户 название", KnowledgeType: "faq", Status: enums.StatusOk, AnswerMode: 1}, locale)
if kb.Name != "用户 название" || kb.KnowledgeTypeName != i18nx.Getf(locale, "knowledge.labels.type.faq") || kb.StatusName != i18nx.Getf(locale, "knowledge.labels.status.0") {
t.Fatalf("unexpected labels/content: %+v", kb)
}
raw := " 原文 answer\n回答: keep this "
faq := BuildKnowledgeFAQ(&models.KnowledgeFAQ{Question: raw, Answer: raw, Status: enums.StatusDisabled, IndexStatus: enums.KnowledgeDocumentIndexStatusFailed, IndexError: "SQL failed password=fixture-only"}, locale)
if faq.Question != raw || faq.Answer != raw {
t.Fatal("authored content changed")
}
if faq.IndexStatusName != i18nx.Getf(locale, "knowledge.labels.index.failed") || faq.StatusName != i18nx.Getf(locale, "knowledge.labels.status.1") {
t.Fatalf("unexpected labels: %+v", faq)
}
if faq.IndexError != i18nx.Getf(locale, "error.knowledge.indexFailed") || strings.Contains(faq.IndexError, "fixture-only") {
t.Fatalf("unsafe index error %q", faq.IndexError)
}
doc := BuildKnowledgeDocument(&models.KnowledgeDocument{Content: raw, IndexError: "provider raw error"}, locale)
list := BuildKnowledgeDocumentList(&models.KnowledgeDocument{IndexError: "provider raw error"}, locale)
if doc.Content != raw || doc.IndexError != faq.IndexError || list.IndexError != faq.IndexError {
t.Fatal("document content or safe error mismatch")
}
log := BuildKnowledgeRetrieveLog(&models.KnowledgeRetrieveLog{Answer: raw, Question: raw, AnswerStatus: 2, TraceData: raw}, locale)
hit := BuildKnowledgeRetrieveHitResponse(&models.KnowledgeRetrieveHit{Snippet: raw, ChunkType: "faq"}, locale)
if log.Answer != raw || log.Question != raw || log.TraceData != raw || hit.Snippet != raw {
t.Fatal("historical data was rewritten")
}
if log.AnswerStatusName != i18nx.Getf(locale, "knowledge.labels.answer.2") {
t.Fatalf("answer label = %q", log.AnswerStatusName)
}
})
}
if BuildKnowledgeFAQ(&models.KnowledgeFAQ{}, "en-US").IndexError != "" {
t.Fatal("empty error should remain empty")
}
}