diff --git a/Makefile b/Makefile index 54d7091..f119150 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,8 @@ LAZYRAG_AUTH_API_PERMISSIONS_FILE ?= # Core / ACL ACL_DB_DRIVER ?= postgres ACL_DB_DSN ?= host=db user=app password=app dbname=app port=5432 sslmode=disable TimeZone=UTC -LAZYRAG_CHAT_SERVICE_URL ?= http://localhost:8046 +# For docker-compose, core reaches chat via service DNS name. +LAZYRAG_CHAT_SERVICE_URL ?= http://chat:8046 # Processor LAZYRAG_DOCUMENT_PROCESSOR_PORT ?= 8000 diff --git a/algorithm/chat/chat.py b/algorithm/chat/chat.py index d9eb077..1382a5b 100644 --- a/algorithm/chat/chat.py +++ b/algorithm/chat/chat.py @@ -283,7 +283,7 @@ async def health(): @app.post('/api/chat/stream', summary='与知识库对话') async def chat( query: str = Body(..., description='用户问题'), # noqa: B008 - history: List[History] = Body(default=None, description='历史对话,可为 list 或省略(代理可能传为 {})'), # noqa: B008 + history: List[History] = Body(default_factory=list, description='历史对话,可为 list 或省略(代理可能传为 {})'), # noqa: B008 session_id: str = Body('session_id', description='会话 ID'), # noqa: B008 filters: Optional[Dict[str, Any]] = Body(None, description='检索过滤条件'), # noqa: B008 files: Optional[List[str]] = Body(None, description='上传临时文件'), # noqa: B008 diff --git a/backend/core/chat/chat.go b/backend/core/chat/chat.go index 57ec9bb..b35f8e4 100644 --- a/backend/core/chat/chat.go +++ b/backend/core/chat/chat.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net" "net/http" "strings" @@ -139,6 +140,7 @@ func (c *ChatService) StreamChat(ctx context.Context, req *LazyChatRequest) (<-c if err != nil { return nil, err } + fmt.Println("DEBUG upstream request url=", c.streamChatURL, " body=", string(bodyBytes)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.streamChatURL, bytes.NewReader(bodyBytes)) if err != nil { return nil, err @@ -147,8 +149,10 @@ func (c *ChatService) StreamChat(ctx context.Context, req *LazyChatRequest) (<-c resp, err := c.client.Do(httpReq) if err != nil { + fmt.Println("DEBUG upstream request failed url=", c.streamChatURL, " err=", err) return nil, err } + fmt.Println("DEBUG upstream response url=", c.streamChatURL, " status=", resp.StatusCode) if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, errors.New("upstream /api/chat_stream returned non-200") @@ -208,6 +212,7 @@ type upstreamStreamLine struct { // body textRequest JSON text map text,baseURL text endpoint(text /api/...)。 func StreamChatUpstream(ctx context.Context, baseURL string, body map[string]any) (<-chan UpstreamStreamChunk, error) { service := NewChatServiceWithEndpoint(baseURL) + fmt.Printf("DEBUG upstream stream request baseURL=%s params=%+v\n", baseURL, body) req := &LazyChatRequest{} if q, ok := body["query"].(string); ok { diff --git a/backend/core/chat/conversation_api_support.go b/backend/core/chat/conversation_api_support.go index 02eee75..4223471 100644 --- a/backend/core/chat/conversation_api_support.go +++ b/backend/core/chat/conversation_api_support.go @@ -15,7 +15,7 @@ func chatServiceURL() string { if u := os.Getenv("LAZYRAG_CHAT_SERVICE_URL"); u != "" { return u } - return "http://localhost:8048" + return "http://chat:8046" } func extractMessageForACL(r *http.Request, body []byte) (userID string, items []common.ACLCheckItem) { diff --git a/backend/core/chat/conversation_export.go b/backend/core/chat/conversation_export.go new file mode 100644 index 0000000..eb94601 --- /dev/null +++ b/backend/core/chat/conversation_export.go @@ -0,0 +1,404 @@ +package chat + +import ( + "archive/zip" + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "lazyrag/core/common" + "lazyrag/core/common/orm" + "lazyrag/core/store" +) + +const ( + exportFileTypeUnspecified = "EXPORT_FILE_TYPE_UNSPECIFIED" + exportFileTypeXLSX = "EXPORT_FILE_TYPE_XLSX" + exportFileTypeZIP = "EXPORT_FILE_TYPE_ZIP" +) + +type ExportConversationsRequest struct { + ConversationIDs []string `json:"conversation_ids,omitempty"` + FileTypes []string `json:"file_types"` + Keyword string `json:"keyword,omitempty"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + FeedBack *int `json:"feed_back,omitempty"` + CreateUserNames []string `json:"create_user_names,omitempty"` +} + +type ExportConversationsResponse struct { + Uris []string `json:"uris,omitempty"` +} + +type exportConversationBundle struct { + Conversation orm.Conversation `json:"conversation"` + Histories []orm.ChatHistory `json:"histories"` +} + +type exportFileMeta struct { + Path string + FileName string + ContentType string + UserID string + ExpireAt time.Time +} + +var ( + exportFilesMu sync.Mutex + exportFiles = map[string]exportFileMeta{} +) + +func ExportConversations(w http.ResponseWriter, r *http.Request) { + userID := strings.TrimSpace(store.UserID(r)) + if userID == "" { + userID = "0" + } + var req ExportConversationsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + common.ReplyErr(w, "invalid body", http.StatusBadRequest) + return + } + fileTypes := normalizeExportFileTypes(req.FileTypes) + if len(fileTypes) == 0 { + fileTypes = []string{exportFileTypeXLSX} + } + + startAt, err := parseOptionalTime(req.StartTime) + if err != nil { + common.ReplyErr(w, "invalid start_time", http.StatusBadRequest) + return + } + endAt, err := parseOptionalTime(req.EndTime) + if err != nil { + common.ReplyErr(w, "invalid end_time", http.StatusBadRequest) + return + } + if !startAt.IsZero() && !endAt.IsZero() && endAt.Before(startAt) { + common.ReplyErr(w, "end_time must be greater than or equal to start_time", http.StatusBadRequest) + return + } + + conversations, historiesByConvID, err := loadConversationsForExport(r, userID, req, startAt, endAt) + if err != nil { + common.ReplyErr(w, "query conversations failed", http.StatusInternalServerError) + return + } + if len(conversations) == 0 { + common.ReplyJSON(w, ExportConversationsResponse{Uris: []string{}}) + return + } + + exportFilesMu.Lock() + purgeExpiredExportFilesLocked(time.Now().UTC()) + exportFilesMu.Unlock() + + bundles := make([]exportConversationBundle, 0, len(conversations)) + for _, conv := range conversations { + bundles = append(bundles, exportConversationBundle{ + Conversation: conv, + Histories: historiesByConvID[conv.ID], + }) + } + + uris := make([]string, 0, len(fileTypes)) + for _, ft := range fileTypes { + meta, err := buildExportFile(bundles, userID, ft) + if err != nil { + common.ReplyErr(w, "export conversations failed", http.StatusInternalServerError) + return + } + token := newID("export_") + exportFilesMu.Lock() + exportFiles[token] = meta + exportFilesMu.Unlock() + uris = append(uris, "/api/v1/conversation:export/files/"+token) + } + common.ReplyJSON(w, ExportConversationsResponse{Uris: uris}) +} + +func DownloadExportConversationFile(w http.ResponseWriter, r *http.Request) { + userID := strings.TrimSpace(store.UserID(r)) + if userID == "" { + userID = "0" + } + fileID := strings.TrimSpace(common.PathVar(r, "file_id")) + if fileID == "" { + common.ReplyErr(w, "missing file_id", http.StatusBadRequest) + return + } + + now := time.Now().UTC() + exportFilesMu.Lock() + purgeExpiredExportFilesLocked(now) + meta, ok := exportFiles[fileID] + exportFilesMu.Unlock() + if !ok { + common.ReplyErr(w, "export file not found", http.StatusNotFound) + return + } + if meta.UserID != userID { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(common.ForbiddenBody)) + return + } + + f, err := os.Open(meta.Path) + if err != nil { + common.ReplyErr(w, "export file not found", http.StatusNotFound) + return + } + defer f.Close() + + w.Header().Set("Content-Type", meta.ContentType) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", meta.FileName)) + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, f) +} + +func loadConversationsForExport( + r *http.Request, + userID string, + req ExportConversationsRequest, + startAt time.Time, + endAt time.Time, +) ([]orm.Conversation, map[string][]orm.ChatHistory, error) { + db := store.DB().WithContext(r.Context()) + q := db.Model(&orm.Conversation{}).Where("create_user_id = ?", userID) + if len(req.ConversationIDs) > 0 { + ids := make([]string, 0, len(req.ConversationIDs)) + for _, id := range req.ConversationIDs { + if s := strings.TrimSpace(id); s != "" { + ids = append(ids, s) + } + } + if len(ids) == 0 { + return []orm.Conversation{}, map[string][]orm.ChatHistory{}, nil + } + q = q.Where("id IN ?", ids) + } + if kw := strings.TrimSpace(req.Keyword); kw != "" { + q = q.Where("display_name LIKE ?", "%"+kw+"%") + } + if len(req.CreateUserNames) > 0 { + names := make([]string, 0, len(req.CreateUserNames)) + for _, n := range req.CreateUserNames { + if s := strings.TrimSpace(n); s != "" { + names = append(names, s) + } + } + if len(names) > 0 { + q = q.Where("create_user_name IN ?", names) + } + } + if !startAt.IsZero() { + q = q.Where("created_at >= ?", startAt) + } + if !endAt.IsZero() { + q = q.Where("created_at <= ?", endAt) + } + var conversations []orm.Conversation + if err := q.Order("updated_at DESC").Find(&conversations).Error; err != nil { + return nil, nil, err + } + if len(conversations) == 0 { + return []orm.Conversation{}, map[string][]orm.ChatHistory{}, nil + } + + convIDs := make([]string, 0, len(conversations)) + for _, c := range conversations { + convIDs = append(convIDs, c.ID) + } + hq := db.Model(&orm.ChatHistory{}).Where("conversation_id IN ?", convIDs) + if req.FeedBack != nil && *req.FeedBack > 0 { + hq = hq.Where("feed_back = ?", *req.FeedBack) + } + var histories []orm.ChatHistory + if err := hq.Order("conversation_id ASC, seq ASC, create_time ASC").Find(&histories).Error; err != nil { + return nil, nil, err + } + byConv := make(map[string][]orm.ChatHistory, len(conversations)) + for _, h := range histories { + byConv[h.ConversationID] = append(byConv[h.ConversationID], h) + } + return conversations, byConv, nil +} + +func buildExportFile(bundles []exportConversationBundle, userID string, fileType string) (exportFileMeta, error) { + now := time.Now().UTC() + switch fileType { + case exportFileTypeZIP: + data, err := buildConversationsZIP(bundles) + if err != nil { + return exportFileMeta{}, err + } + path, err := writeTempExportFile("conversations-export-*.zip", data) + if err != nil { + return exportFileMeta{}, err + } + return exportFileMeta{ + Path: path, + FileName: fmt.Sprintf("conversations_%s.zip", now.Format("20060102_150405")), + ContentType: "application/zip", + UserID: userID, + ExpireAt: now.Add(2 * time.Hour), + }, nil + default: + data, err := buildConversationsCSV(bundles) + if err != nil { + return exportFileMeta{}, err + } + path, err := writeTempExportFile("conversations-export-*.csv", data) + if err != nil { + return exportFileMeta{}, err + } + return exportFileMeta{ + Path: path, + FileName: fmt.Sprintf("conversations_%s.csv", now.Format("20060102_150405")), + ContentType: "text/csv; charset=utf-8", + UserID: userID, + ExpireAt: now.Add(2 * time.Hour), + }, nil + } +} + +func buildConversationsCSV(bundles []exportConversationBundle) ([]byte, error) { + var buf bytes.Buffer + w := csv.NewWriter(&buf) + _ = w.Write([]string{ + "conversation_id", "display_name", "create_user_name", "conversation_create_time", "conversation_update_time", + "seq", "query", "result", "feed_back", "reason", "expected_answer", "history_create_time", + }) + for _, bundle := range bundles { + conv := bundle.Conversation + if len(bundle.Histories) == 0 { + _ = w.Write([]string{ + conv.ID, + conv.DisplayName, + conv.CreateUserName, + conv.CreatedAt.UTC().Format(time.RFC3339), + conv.UpdatedAt.UTC().Format(time.RFC3339), + "", "", "", "", "", "", "", + }) + continue + } + for _, h := range bundle.Histories { + _ = w.Write([]string{ + conv.ID, + conv.DisplayName, + conv.CreateUserName, + conv.CreatedAt.UTC().Format(time.RFC3339), + conv.UpdatedAt.UTC().Format(time.RFC3339), + fmt.Sprintf("%d", h.Seq), + h.RawContent, + h.Result, + fmt.Sprintf("%d", h.FeedBack), + h.Reason, + h.ExpectedAnswer, + h.CreateTime.UTC().Format(time.RFC3339), + }) + } + } + w.Flush() + if err := w.Error(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func buildConversationsZIP(bundles []exportConversationBundle) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + jsonFile, err := zw.Create("conversations.json") + if err != nil { + return nil, err + } + payload, _ := json.MarshalIndent(map[string]any{"conversations": bundles}, "", " ") + if _, err := jsonFile.Write(payload); err != nil { + return nil, err + } + + csvData, err := buildConversationsCSV(bundles) + if err != nil { + return nil, err + } + csvFile, err := zw.Create("conversations.csv") + if err != nil { + return nil, err + } + if _, err := csvFile.Write(csvData); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeTempExportFile(pattern string, data []byte) (string, error) { + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + defer f.Close() + if _, err := f.Write(data); err != nil { + return "", err + } + return f.Name(), nil +} + +func purgeExpiredExportFilesLocked(now time.Time) { + for key, meta := range exportFiles { + if now.Before(meta.ExpireAt) { + continue + } + _ = os.Remove(meta.Path) + delete(exportFiles, key) + } +} + +func parseOptionalTime(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, nil + } + layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} + for _, layout := range layouts { + if t, err := time.Parse(layout, raw); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("invalid time") +} + +func normalizeExportFileTypes(fileTypes []string) []string { + if len(fileTypes) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(fileTypes)) + for _, ft := range fileTypes { + v := strings.TrimSpace(ft) + if v == "" || v == exportFileTypeUnspecified { + continue + } + if v != exportFileTypeXLSX && v != exportFileTypeZIP { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} diff --git a/backend/core/chat/conversation_logic.go b/backend/core/chat/conversation_logic.go index c829be9..1ac39d0 100644 --- a/backend/core/chat/conversation_logic.go +++ b/backend/core/chat/conversation_logic.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "fmt" "net/http" "strings" "sync" @@ -269,11 +270,15 @@ func handleNonStreamChat( seq int, ) { pyBody, _ := json.Marshal(reqBody) - respBytes, _, err := common.HTTPPost(reqCtx, baseURL+"/api/chat", "application/json", pyBody) + upstreamURL := baseURL + "/api/chat" + fmt.Printf("DEBUG upstream request url=%s params=%+v\n", upstreamURL, reqBody) + respBytes, statusCode, err := common.HTTPPost(reqCtx, upstreamURL, "application/json", pyBody) if err != nil { + fmt.Println("DEBUG upstream request failed url=", upstreamURL, " err=", err) common.ReplyErr(w, "chat service unavailable", http.StatusBadGateway) return } + fmt.Println("DEBUG upstream response url=", upstreamURL, " status=", statusCode) var pyResp struct { Code int `json:"code"` Msg string `json:"msg"` diff --git a/backend/core/doc/document.go b/backend/core/doc/document.go index e8e0419..2653947 100644 --- a/backend/core/doc/document.go +++ b/backend/core/doc/document.go @@ -695,6 +695,147 @@ func SearchAllDocuments(w http.ResponseWriter, r *http.Request) { } common.ReplyJSON(w, ListDocumentsResponse{Documents: out, TotalSize: int32(len(out)), NextPageToken: ""}) } + +func BatchUpdateDocumentTags(w http.ResponseWriter, r *http.Request) { + datasetID := datasetIDFromPath(r) + if datasetID == "" { + common.ReplyErr(w, "missing dataset", http.StatusBadRequest) + return + } + if _, userID, ok := requireDatasetPermission(r, datasetID, acl.PermissionDatasetWrite); !ok { + if userID == "" { + common.ReplyErr(w, "missing X-User-Id", http.StatusBadRequest) + } else { + replyDatasetForbidden(w) + } + return + } + + var req BatchUpdateDocumentTagsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + common.ReplyErr(w, "invalid body", http.StatusBadRequest) + return + } + parent := strings.TrimSpace(req.Parent) + if parent == "" { + common.ReplyErr(w, "parent required", http.StatusBadRequest) + return + } + parentDatasetID := strings.TrimPrefix(parent, "datasets/") + if parentDatasetID == "" || parentDatasetID != datasetID { + common.ReplyErr(w, "parent does not match dataset", http.StatusBadRequest) + return + } + + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = "UPDATE_MODE_UNSPECIFIED" + } + if mode != "UPDATE_MODE_UNSPECIFIED" && mode != "APPEND" && mode != "OVERWRITE" { + common.ReplyErr(w, "invalid mode", http.StatusBadRequest) + return + } + + targetIDs := map[string]struct{}{} + for _, id := range req.DocumentIDs { + id = strings.TrimSpace(id) + if id != "" { + targetIDs[id] = struct{}{} + } + } + for _, folderID := range req.FolderIDs { + folderID = strings.TrimSpace(folderID) + if folderID == "" { + continue + } + subtree, err := loadDocumentSubtree(r.Context(), datasetID, folderID) + if err != nil { + common.ReplyErr(w, "folder not found", http.StatusBadRequest) + return + } + for _, row := range subtree { + if isFolderLikeDocument(row) { + continue + } + targetIDs[strings.TrimSpace(row.ID)] = struct{}{} + } + } + + if len(targetIDs) == 0 { + common.ReplyJSON(w, BatchUpdateDocumentTagsResponse{AffectedFiles: 0, TruncatedDocs: 0}) + return + } + + ids := make([]string, 0, len(targetIDs)) + for id := range targetIDs { + ids = append(ids, id) + } + + var docs []orm.Document + if err := store.DB().WithContext(r.Context()). + Where("dataset_id = ? AND id IN ? AND deleted_at IS NULL", datasetID, ids). + Find(&docs).Error; err != nil { + common.ReplyErr(w, "query documents failed", http.StatusInternalServerError) + return + } + + requestTags := normalizeBatchDocumentTags(req.Tags) + now := time.Now().UTC() + affected := int32(0) + truncated := int32(0) + + for _, docRow := range docs { + if isFolderLikeDocument(docRow) { + continue + } + affected++ + finalTags := append([]string(nil), requestTags...) + if mode == "APPEND" || mode == "UPDATE_MODE_UNSPECIFIED" { + var existing []string + _ = json.Unmarshal(docRow.Tags, &existing) + combined := append(existing, requestTags...) + finalTags = normalizeBatchDocumentTags(combined) + } + if len(finalTags) > 10 { + finalTags = finalTags[:10] + truncated++ + } + tagsBytes, _ := json.Marshal(finalTags) + if err := store.DB().WithContext(r.Context()). + Model(&orm.Document{}). + Where("dataset_id = ? AND id = ? AND deleted_at IS NULL", datasetID, docRow.ID). + Updates(map[string]any{"tags": tagsBytes, "updated_at": now}).Error; err != nil { + common.ReplyErr(w, "update document tags failed", http.StatusInternalServerError) + return + } + } + + common.ReplyJSON(w, BatchUpdateDocumentTagsResponse{ + AffectedFiles: affected, + TruncatedDocs: truncated, + }) +} + +func normalizeBatchDocumentTags(tags []string) []string { + if len(tags) == 0 { + return []string{} + } + seen := make(map[string]struct{}, len(tags)) + out := make([]string, 0, len(tags)) + for _, tag := range tags { + t := strings.TrimSpace(tag) + if t == "" { + continue + } + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + out = append(out, t) + } + return out +} + func BatchDeleteDocument(w http.ResponseWriter, r *http.Request) { datasetID := datasetIDFromPath(r) userID := store.UserID(r) @@ -853,6 +994,19 @@ type SearchDocumentsRequest struct { Recursive bool `json:"recursive,omitempty"` } +type BatchUpdateDocumentTagsRequest struct { + Parent string `json:"parent"` + DocumentIDs []string `json:"document_ids,omitempty"` + FolderIDs []string `json:"folder_ids,omitempty"` + Mode string `json:"mode"` + Tags []string `json:"tags"` +} + +type BatchUpdateDocumentTagsResponse struct { + AffectedFiles int32 `json:"affected_files,omitempty"` + TruncatedDocs int32 `json:"truncated_docs,omitempty"` +} + type BatchDeleteDocumentRequest struct { Parent string `json:"parent"` Names []string `json:"names"` diff --git a/backend/core/routes.go b/backend/core/routes.go index 05577c6..bd4ee39 100644 --- a/backend/core/routes.go +++ b/backend/core/routes.go @@ -32,6 +32,7 @@ func registerAllRoutes(r *mux.Router) { handleAPI(r, "DELETE", "/datasets/{dataset}/documents/{document}", []string{"document.write"}, doc.DeleteDocument) handleAPI(r, "PATCH", "/datasets/{dataset}/documents/{document}", []string{"document.write"}, doc.UpdateDocument) handleAPI(r, "POST", "/datasets/{dataset}/documents:search", []string{"document.read"}, doc.SearchDocuments) + handleAPI(r, "POST", "/datasets/{dataset}/documents:batchUpdateTags", []string{"document.write"}, doc.BatchUpdateDocumentTags) handleAPI(r, "POST", "/documents:search", []string{"document.read"}, doc.SearchAllDocuments) handleAPI(r, "POST", "/datasets/{dataset}:batchDelete", []string{"document.write"}, doc.BatchDeleteDocument) handleAPI(r, "GET", "/document/creators", []string{"document.read"}, doc.AllDocumentCreators) @@ -100,6 +101,8 @@ func registerAllRoutes(r *mux.Router) { handleAPI(r, "GET", "/conversation:switchStatus", []string{"qa.read"}, chat.GetMultiAnswersSwitchStatus) handleAPI(r, "POST", "/conversation:switchStatus", []string{"qa.read"}, chat.SetMultiAnswersSwitchStatus) + handleAPI(r, "POST", "/conversation:export", []string{"qa.read"}, chat.ExportConversations) + handleAPI(r, "GET", "/conversation:export/files/{file_id}", []string{"qa.read"}, chat.DownloadExportConversationFile) // ----- Prompttext ----- handleAPI(r, "POST", "/prompts", []string{"document.write"}, chat.CreatePrompt) diff --git a/frontend/scripts/openapi/.openapi-cache.json b/frontend/scripts/openapi/.openapi-cache.json index c6f5dad..efa708f 100644 --- a/frontend/scripts/openapi/.openapi-cache.json +++ b/frontend/scripts/openapi/.openapi-cache.json @@ -1,4 +1,4 @@ { "auth": "2680796ccf979bf8380ecf6c0e23ccb62524d789bdd2dd1938df302a802051d9", - "core": "e01780c02a6ab9bfa83339df603979277ab13aa0d03484a0245a5fcbd3f24df2" + "core": "02dbd6652862357aa3959fa8a21ff0daa282ae8b10bd1ef3a8f5dd9addc41757" } \ No newline at end of file diff --git a/frontend/scripts/openapi/specs/core.yaml b/frontend/scripts/openapi/specs/core.yaml index 3357005..631470a 100644 --- a/frontend/scripts/openapi/specs/core.yaml +++ b/frontend/scripts/openapi/specs/core.yaml @@ -669,6 +669,36 @@ components: message: type: string type: object + ExportConversationsRequest: + properties: + conversation_ids: + items: + type: string + type: array + create_user_names: + items: + type: string + type: array + end_time: + type: string + feed_back: + type: integer + file_types: + items: + type: string + type: array + keyword: + type: string + start_time: + type: string + type: object + ExportConversationsResponse: + properties: + uris: + items: + type: string + type: array + type: object GetKBAuthorizationResponse: properties: grants: @@ -932,6 +962,10 @@ components: type: string keyword: type: string + keyword_list: + items: + type: string + type: array order_by: type: string p_id: @@ -997,6 +1031,8 @@ components: type: object StartTaskResult: properties: + detail: + type: string display_name: type: string document_id: @@ -1293,7 +1329,9 @@ components: type: object UserInfo: properties: - display_name: + id: + type: string + name: type: string type: object info: @@ -1379,6 +1417,43 @@ paths: "200": description: Streaming response summary: Chat with knowledge base (streaming) + /api/core/conversation:export: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExportConversationsRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ExportConversationsResponse' + description: Export conversation files + summary: Export conversations + tags: + - conversations + /api/core/conversation:export/files/{file_id}: + get: + parameters: + - in: path + name: file_id + required: true + schema: + type: string + responses: + "200": + content: + application/octet-stream: + schema: + format: binary + type: string + description: Exported conversation file + summary: Download exported conversation file + tags: + - conversations /api/core/conversation:switchStatus: get: responses: @@ -1999,6 +2074,18 @@ paths: type: string description: Document download content summary: Download document + /api/core/datasets/{dataset}/documents:batchUpdateTags: + post: + parameters: + - in: path + name: dataset + required: true + schema: + type: string + responses: + "200": + description: OK + summary: POST /datasets/{dataset}/documents:batchUpdateTags /api/core/datasets/{dataset}/documents:search: post: parameters: @@ -2106,6 +2193,53 @@ paths: $ref: '#/components/schemas/DatasetMember' description: Updated member summary: textUser ID Update datasetMember + /api/core/datasets/{dataset}/members/groups/{group_id}: + delete: + parameters: + - in: path + name: dataset + required: true + schema: + type: string + - in: path + name: group_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyObject' + description: Deleted successfully + summary: Delete dataset group member + patch: + parameters: + - in: path + name: dataset + required: true + schema: + type: string + - in: path + name: group_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDatasetMemberRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DatasetMember' + description: Updated member + summary: Update dataset group member /api/core/datasets/{dataset}/members:search: post: parameters: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f147f94..50c2d50 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ -import { BrowserRouter } from "react-router-dom"; -import AppRouter from "./router"; -import { BASENAME } from "./globalState"; +import { BrowserRouter } from 'react-router-dom'; +import AppRouter from './router'; +import { BASENAME } from './globalState'; function App() { + console.log('[App] render', { basename: BASENAME || undefined }); return ( ; + 'create_user_names'?: Array; + 'end_time'?: string; + 'feed_back'?: number; + 'file_types'?: Array; + 'keyword'?: string; + 'start_time'?: string; +} +export interface ExportConversationsResponse { + 'uris'?: Array; +} export interface GetKBAuthorizationResponse { 'grants'?: Array; 'kb_id'?: string; @@ -480,6 +492,7 @@ export interface SearchDatasetMemberRequest { export interface SearchDocumentsRequest { 'dir_path'?: string; 'keyword'?: string; + 'keyword_list'?: Array; 'order_by'?: string; 'p_id'?: string; 'page_size'?: number; @@ -510,6 +523,7 @@ export interface StartTaskRequest { 'task_ids': Array; } export interface StartTaskResult { + 'detail'?: string; 'display_name'?: string; 'document_id'?: string; 'message'?: string; @@ -644,9 +658,194 @@ export interface UploadPartResponse { 'uploaded_parts'?: number; } export interface UserInfo { - 'display_name'?: string; + 'id'?: string; + 'name'?: string; +} + +/** + * ConversationsApi - axios parameter creator + */ +export const ConversationsApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Download exported conversation file + * @param {string} fileId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreConversationExportFilesFileIdGet: async (fileId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'fileId' is not null or undefined + assertParamExists('apiCoreConversationExportFilesFileIdGet', 'fileId', fileId) + const localVarPath = `/api/core/conversation:export/files/{file_id}` + .replace(`{${"file_id"}}`, encodeURIComponent(String(fileId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Accept'] = 'application/octet-stream'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Export conversations + * @param {ExportConversationsRequest} exportConversationsRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreConversationExportPost: async (exportConversationsRequest: ExportConversationsRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'exportConversationsRequest' is not null or undefined + assertParamExists('apiCoreConversationExportPost', 'exportConversationsRequest', exportConversationsRequest) + const localVarPath = `/api/core/conversation:export`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(exportConversationsRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ConversationsApi - functional programming interface + */ +export const ConversationsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ConversationsApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Download exported conversation file + * @param {string} fileId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiCoreConversationExportFilesFileIdGet(fileId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiCoreConversationExportFilesFileIdGet(fileId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ConversationsApi.apiCoreConversationExportFilesFileIdGet']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Export conversations + * @param {ExportConversationsRequest} exportConversationsRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiCoreConversationExportPost(exportConversationsRequest: ExportConversationsRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiCoreConversationExportPost(exportConversationsRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ConversationsApi.apiCoreConversationExportPost']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * ConversationsApi - factory interface + */ +export const ConversationsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ConversationsApiFp(configuration) + return { + /** + * + * @summary Download exported conversation file + * @param {ConversationsApiApiCoreConversationExportFilesFileIdGetRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreConversationExportFilesFileIdGet(requestParameters: ConversationsApiApiCoreConversationExportFilesFileIdGetRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiCoreConversationExportFilesFileIdGet(requestParameters.fileId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Export conversations + * @param {ConversationsApiApiCoreConversationExportPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreConversationExportPost(requestParameters: ConversationsApiApiCoreConversationExportPostRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiCoreConversationExportPost(requestParameters.exportConversationsRequest, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for apiCoreConversationExportFilesFileIdGet operation in ConversationsApi. + */ +export interface ConversationsApiApiCoreConversationExportFilesFileIdGetRequest { + readonly fileId: string +} + +/** + * Request parameters for apiCoreConversationExportPost operation in ConversationsApi. + */ +export interface ConversationsApiApiCoreConversationExportPostRequest { + readonly exportConversationsRequest: ExportConversationsRequest } +/** + * ConversationsApi - object-oriented interface + */ +export class ConversationsApi extends BaseAPI { + /** + * + * @summary Download exported conversation file + * @param {ConversationsApiApiCoreConversationExportFilesFileIdGetRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public apiCoreConversationExportFilesFileIdGet(requestParameters: ConversationsApiApiCoreConversationExportFilesFileIdGetRequest, options?: RawAxiosRequestConfig) { + return ConversationsApiFp(this.configuration).apiCoreConversationExportFilesFileIdGet(requestParameters.fileId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Export conversations + * @param {ConversationsApiApiCoreConversationExportPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public apiCoreConversationExportPost(requestParameters: ConversationsApiApiCoreConversationExportPostRequest, options?: RawAxiosRequestConfig) { + return ConversationsApiFp(this.configuration).apiCoreConversationExportPost(requestParameters.exportConversationsRequest, options).then((request) => request(this.axios, this.basePath)); + } +} + + + /** * DatasetsApi - axios parameter creator */ @@ -1938,6 +2137,39 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati options: localVarRequestOptions, }; }, + /** + * + * @summary POST /datasets/{dataset}/documents:batchUpdateTags + * @param {string} dataset + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost: async (dataset: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'dataset' is not null or undefined + assertParamExists('apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost', 'dataset', dataset) + const localVarPath = `/api/core/datasets/{dataset}/documents:batchUpdateTags` + .replace(`{${"dataset"}}`, encodeURIComponent(String(dataset))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @summary Preview document content @@ -2163,6 +2395,87 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati options: localVarRequestOptions, }; }, + /** + * + * @summary Delete dataset group member + * @param {string} dataset + * @param {string} groupId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreDatasetsDatasetMembersGroupsGroupIdDelete: async (dataset: string, groupId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'dataset' is not null or undefined + assertParamExists('apiCoreDatasetsDatasetMembersGroupsGroupIdDelete', 'dataset', dataset) + // verify required parameter 'groupId' is not null or undefined + assertParamExists('apiCoreDatasetsDatasetMembersGroupsGroupIdDelete', 'groupId', groupId) + const localVarPath = `/api/core/datasets/{dataset}/members/groups/{group_id}` + .replace(`{${"dataset"}}`, encodeURIComponent(String(dataset))) + .replace(`{${"group_id"}}`, encodeURIComponent(String(groupId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Update dataset group member + * @param {string} dataset + * @param {string} groupId + * @param {UpdateDatasetMemberRequest} updateDatasetMemberRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreDatasetsDatasetMembersGroupsGroupIdPatch: async (dataset: string, groupId: string, updateDatasetMemberRequest: UpdateDatasetMemberRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'dataset' is not null or undefined + assertParamExists('apiCoreDatasetsDatasetMembersGroupsGroupIdPatch', 'dataset', dataset) + // verify required parameter 'groupId' is not null or undefined + assertParamExists('apiCoreDatasetsDatasetMembersGroupsGroupIdPatch', 'groupId', groupId) + // verify required parameter 'updateDatasetMemberRequest' is not null or undefined + assertParamExists('apiCoreDatasetsDatasetMembersGroupsGroupIdPatch', 'updateDatasetMemberRequest', updateDatasetMemberRequest) + const localVarPath = `/api/core/datasets/{dataset}/members/groups/{group_id}` + .replace(`{${"dataset"}}`, encodeURIComponent(String(dataset))) + .replace(`{${"group_id"}}`, encodeURIComponent(String(groupId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateDatasetMemberRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @summary Search dataset members @@ -3832,6 +4145,19 @@ export const DefaultApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['DefaultApi.apiCoreDatasetsDatasetBatchAddMemberPost']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * + * @summary POST /datasets/{dataset}/documents:batchUpdateTags + * @param {string} dataset + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost(dataset: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost(dataset, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * * @summary Preview document content @@ -3916,6 +4242,35 @@ export const DefaultApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['DefaultApi.apiCoreDatasetsDatasetMembersGet']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * + * @summary Delete dataset group member + * @param {string} dataset + * @param {string} groupId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiCoreDatasetsDatasetMembersGroupsGroupIdDelete(dataset: string, groupId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiCoreDatasetsDatasetMembersGroupsGroupIdDelete(dataset, groupId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.apiCoreDatasetsDatasetMembersGroupsGroupIdDelete']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Update dataset group member + * @param {string} dataset + * @param {string} groupId + * @param {UpdateDatasetMemberRequest} updateDatasetMemberRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiCoreDatasetsDatasetMembersGroupsGroupIdPatch(dataset: string, groupId: string, updateDatasetMemberRequest: UpdateDatasetMemberRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiCoreDatasetsDatasetMembersGroupsGroupIdPatch(dataset, groupId, updateDatasetMemberRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.apiCoreDatasetsDatasetMembersGroupsGroupIdPatch']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * * @summary Search dataset members @@ -4620,6 +4975,16 @@ export const DefaultApiFactory = function (configuration?: Configuration, basePa apiCoreDatasetsDatasetBatchAddMemberPost(requestParameters: DefaultApiApiCoreDatasetsDatasetBatchAddMemberPostRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.apiCoreDatasetsDatasetBatchAddMemberPost(requestParameters.dataset, requestParameters.batchAddDatasetMemberRequest, options).then((request) => request(axios, basePath)); }, + /** + * + * @summary POST /datasets/{dataset}/documents:batchUpdateTags + * @param {DefaultApiApiCoreDatasetsDatasetDocumentsBatchUpdateTagsPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost(requestParameters: DefaultApiApiCoreDatasetsDatasetDocumentsBatchUpdateTagsPostRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost(requestParameters.dataset, options).then((request) => request(axios, basePath)); + }, /** * * @summary Preview document content @@ -4680,6 +5045,26 @@ export const DefaultApiFactory = function (configuration?: Configuration, basePa apiCoreDatasetsDatasetMembersGet(requestParameters: DefaultApiApiCoreDatasetsDatasetMembersGetRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.apiCoreDatasetsDatasetMembersGet(requestParameters.dataset, options).then((request) => request(axios, basePath)); }, + /** + * + * @summary Delete dataset group member + * @param {DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdDeleteRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreDatasetsDatasetMembersGroupsGroupIdDelete(requestParameters: DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdDeleteRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiCoreDatasetsDatasetMembersGroupsGroupIdDelete(requestParameters.dataset, requestParameters.groupId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Update dataset group member + * @param {DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdPatchRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiCoreDatasetsDatasetMembersGroupsGroupIdPatch(requestParameters: DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiCoreDatasetsDatasetMembersGroupsGroupIdPatch(requestParameters.dataset, requestParameters.groupId, requestParameters.updateDatasetMemberRequest, options).then((request) => request(axios, basePath)); + }, /** * * @summary Search dataset members @@ -5169,6 +5554,13 @@ export interface DefaultApiApiCoreDatasetsDatasetBatchAddMemberPostRequest { readonly batchAddDatasetMemberRequest: BatchAddDatasetMemberRequest } +/** + * Request parameters for apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost operation in DefaultApi. + */ +export interface DefaultApiApiCoreDatasetsDatasetDocumentsBatchUpdateTagsPostRequest { + readonly dataset: string +} + /** * Request parameters for apiCoreDatasetsDatasetDocumentsDocumentContentGet operation in DefaultApi. */ @@ -5223,6 +5615,26 @@ export interface DefaultApiApiCoreDatasetsDatasetMembersGetRequest { readonly dataset: string } +/** + * Request parameters for apiCoreDatasetsDatasetMembersGroupsGroupIdDelete operation in DefaultApi. + */ +export interface DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdDeleteRequest { + readonly dataset: string + + readonly groupId: string +} + +/** + * Request parameters for apiCoreDatasetsDatasetMembersGroupsGroupIdPatch operation in DefaultApi. + */ +export interface DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdPatchRequest { + readonly dataset: string + + readonly groupId: string + + readonly updateDatasetMemberRequest: UpdateDatasetMemberRequest +} + /** * Request parameters for apiCoreDatasetsDatasetMembersSearchPost operation in DefaultApi. */ @@ -5716,6 +6128,17 @@ export class DefaultApi extends BaseAPI { return DefaultApiFp(this.configuration).apiCoreDatasetsDatasetBatchAddMemberPost(requestParameters.dataset, requestParameters.batchAddDatasetMemberRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary POST /datasets/{dataset}/documents:batchUpdateTags + * @param {DefaultApiApiCoreDatasetsDatasetDocumentsBatchUpdateTagsPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost(requestParameters: DefaultApiApiCoreDatasetsDatasetDocumentsBatchUpdateTagsPostRequest, options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).apiCoreDatasetsDatasetDocumentsBatchUpdateTagsPost(requestParameters.dataset, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary Preview document content @@ -5782,6 +6205,28 @@ export class DefaultApi extends BaseAPI { return DefaultApiFp(this.configuration).apiCoreDatasetsDatasetMembersGet(requestParameters.dataset, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary Delete dataset group member + * @param {DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdDeleteRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public apiCoreDatasetsDatasetMembersGroupsGroupIdDelete(requestParameters: DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdDeleteRequest, options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).apiCoreDatasetsDatasetMembersGroupsGroupIdDelete(requestParameters.dataset, requestParameters.groupId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Update dataset group member + * @param {DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdPatchRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public apiCoreDatasetsDatasetMembersGroupsGroupIdPatch(requestParameters: DefaultApiApiCoreDatasetsDatasetMembersGroupsGroupIdPatchRequest, options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).apiCoreDatasetsDatasetMembersGroupsGroupIdPatch(requestParameters.dataset, requestParameters.groupId, requestParameters.updateDatasetMemberRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary Search dataset members diff --git a/frontend/src/components/LanguageSwitcher/index.scss b/frontend/src/components/LanguageSwitcher/index.scss index 09ab096..2a2e202 100644 --- a/frontend/src/components/LanguageSwitcher/index.scss +++ b/frontend/src/components/LanguageSwitcher/index.scss @@ -4,8 +4,15 @@ .ant-select-selector { background: transparent !important; border: none !important; - color: inherit; + color: inherit !important; + border-radius: 0 !important; box-shadow: none !important; + padding-inline: 0 !important; + } + + .ant-select-selection-item { + font-size: 13px; + font-weight: 500; } .ant-select-arrow { diff --git a/frontend/src/components/request.ts b/frontend/src/components/request.ts index 137f612..9a753b7 100644 --- a/frontend/src/components/request.ts +++ b/frontend/src/components/request.ts @@ -2,6 +2,7 @@ import axios from "axios"; import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; import { message } from "antd"; import { AgentAppsAuth } from "@/components/auth"; +import i18n from "@/i18n"; export const BASE_URL = (typeof import.meta !== "undefined" && @@ -53,8 +54,35 @@ function isCanceledError(error: any): boolean { ); } -function extractErrorMessage(error: any): string | undefined { - const responseData = error?.response?.data; +function getErrorPayload(error: any): any { + return error?.response?.data ?? error; +} + +export function extractErrorCode(error: any): string | undefined { + const responseData = getErrorPayload(error); + const candidates = [ + responseData?.code, + responseData?.error_code, + responseData?.errorCode, + responseData?.data?.code, + responseData?.data?.error_code, + responseData?.data?.errorCode, + ]; + + for (const candidate of candidates) { + if (candidate !== undefined && candidate !== null) { + const normalized = String(candidate).trim(); + if (normalized) { + return normalized; + } + } + } + + return undefined; +} + +function extractRawErrorMessage(error: any): string | undefined { + const responseData = getErrorPayload(error); const detail = responseData?.detail; if (Array.isArray(detail)) { @@ -94,6 +122,19 @@ function extractErrorMessage(error: any): string | undefined { return undefined; } +export function getLocalizedErrorMessage( + error: any, + fallback?: string, +): string | undefined { + const errorCode = extractErrorCode(error); + + if (errorCode && i18n.exists(`errors.${errorCode}`)) { + return i18n.t(`errors.${errorCode}`); + } + + return extractRawErrorMessage(error) || fallback; +} + function isRefreshEndpoint(url?: string): boolean { if (!url) return false; return url.includes("/auth/refresh") || url.includes("/auth/login") || url.includes("/auth/logout"); @@ -106,19 +147,26 @@ export const handleError = async (error: AxiosError) => { if (error.response) { if (error.response.status === 403) { - const errMsg = extractErrorMessage(error); - if (errMsg === "User is disabled") { - message.error("用户被禁用"); + const errMsg = getLocalizedErrorMessage( + error, + i18n.t("common.accessDenied"), + ); + const errorCode = extractErrorCode(error); + if ( + errorCode === "1000106" || + extractRawErrorMessage(error) === "User is disabled" + ) { + message.error(errMsg || i18n.t("auth.userDisabled")); void AgentAppsAuth.logout( `${BASE_URL || window.location.origin}${window.BASENAME || ""}/agent/chat`, ); return Promise.reject(error); } - message.error(errMsg || "访问被拒绝"); + message.error(errMsg || i18n.t("common.accessDenied")); } else if (error.response.status === 401) { if (isRefreshEndpoint(originalRequest?.url)) { if (AgentAppsAuth.isLoggedIn()) { - message.warning("登录状态已失效,请重新登录"); + message.warning(i18n.t("auth.sessionExpired")); } void AgentAppsAuth.logout(); return Promise.reject(error); @@ -126,7 +174,7 @@ export const handleError = async (error: AxiosError) => { if (!originalRequest || originalRequest._retry) { if (AgentAppsAuth.isLoggedIn()) { - message.warning("认证失败,请重新登录"); + message.warning(i18n.t("auth.authFailedRelogin")); } void AgentAppsAuth.logout(); return Promise.reject(error); @@ -165,19 +213,25 @@ export const handleError = async (error: AxiosError) => { }); refreshQueue = []; - message.warning("登录状态已失效,请重新登录"); + message.warning(i18n.t("auth.sessionExpired")); void AgentAppsAuth.logout(); return Promise.reject(refreshError); } finally { isRefreshing = false; } } else { - message.error(extractErrorMessage(error) || "请求失败"); + message.error( + getLocalizedErrorMessage(error, i18n.t("common.requestFailed")) || + i18n.t("common.requestFailed"), + ); } } else if (error.request) { - message.error("服务器无响应"); + message.error(i18n.t("common.serverNoResponse")); } else { - message.error(error.message || "请求发生错误"); + message.error( + getLocalizedErrorMessage(error, i18n.t("common.requestError")) || + i18n.t("common.requestError"), + ); } return Promise.reject(error); }; diff --git a/frontend/src/components/ui/CommonModal.tsx b/frontend/src/components/ui/CommonModal.tsx index c157ac1..22b75c3 100644 --- a/frontend/src/components/ui/CommonModal.tsx +++ b/frontend/src/components/ui/CommonModal.tsx @@ -1,5 +1,6 @@ import { Modal, Button } from "antd"; import type { ButtonProps } from "antd"; +import { useTranslation } from "react-i18next"; interface CommonModalProps { contentText: React.ReactNode; @@ -16,6 +17,7 @@ interface CommonModalProps { } export default function CommonModal(props: CommonModalProps) { + const { t } = useTranslation(); const { contentText, successFn, @@ -24,8 +26,8 @@ export default function CommonModal(props: CommonModalProps) { isBtn = true, width = 420, loading = false, - cancelText = "取消", - confirmText = "确认", + cancelText = t("common.cancel"), + confirmText = t("common.confirm"), btnType = "primary", disable = false, } = props; diff --git a/frontend/src/components/ui/ListPageTable.tsx b/frontend/src/components/ui/ListPageTable.tsx index 9d91bb4..4efa39d 100644 --- a/frontend/src/components/ui/ListPageTable.tsx +++ b/frontend/src/components/ui/ListPageTable.tsx @@ -1,4 +1,6 @@ import { Table, type TableProps } from "antd"; +import { useTranslation } from "react-i18next"; +import { getLocalizedTablePagination } from "./pagination"; import { useStyles } from "./useStyles"; const listTableCss = ` @@ -64,17 +66,26 @@ export default function ListPageTable(props: ListPageTableProps) { rootClassName = "", style, scroll, + pagination, ...restProps } = props; + const { t } = useTranslation(); useStyles("list-page-table-styles", listTableCss); const minWidth = scroll ? resolveMinWidth(scroll.x) : undefined; + const localizedPagination = getLocalizedTablePagination(pagination, t); return (
{title &&
{title}
}
- +
diff --git a/frontend/src/components/ui/pagination.ts b/frontend/src/components/ui/pagination.ts new file mode 100644 index 0000000..04945a8 --- /dev/null +++ b/frontend/src/components/ui/pagination.ts @@ -0,0 +1,22 @@ +import type { TablePaginationConfig } from "antd"; +import type { TFunction } from "i18next"; + +export type TablePaginationProp = TablePaginationConfig | false | undefined; + +export function getLocalizedTablePagination( + pagination: TablePaginationProp, + t: TFunction, +): TablePaginationProp { + if (!pagination) { + return pagination; + } + + return { + ...pagination, + locale: { + ...pagination.locale, + items_per_page: t("common.itemsPerPageSuffix"), + page_size: t("common.pageSize"), + }, + }; +} diff --git a/frontend/src/i18n/antdLocale.ts b/frontend/src/i18n/antdLocale.ts new file mode 100644 index 0000000..f9db079 --- /dev/null +++ b/frontend/src/i18n/antdLocale.ts @@ -0,0 +1,11 @@ +import enUS from "antd/locale/en_US"; +import zhCN from "antd/locale/zh_CN"; +import type { Locale } from "antd/es/locale"; + +export function getAntdLocale(language?: string): Locale { + if (language?.toLowerCase().startsWith("en")) { + return enUS; + } + + return zhCN; +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index f98252c..eb09824 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -1,3 +1,5 @@ +import { enUSErrorMessages } from "./error-codes"; + const enUS = { // Common common: { @@ -12,6 +14,7 @@ const enUS = { loading: "Loading...", noData: "No data", success: "Success", + saveSuccess: "Saved successfully", failed: "Failed", sortBy: "Sort by", sort: "Sort", @@ -19,6 +22,12 @@ const enUS = { description: "Description", actions: "Actions", totalItems: "Total {{total}} items", + itemsPerPageSuffix: "/ page", + pageSize: "Page size", + accessDenied: "Access denied", + requestFailed: "Request failed", + serverNoResponse: "No response from server", + requestError: "An error occurred while processing the request", }, // Auth @@ -42,6 +51,12 @@ const enUS = { loginFailed: "Login failed, please check your credentials", registerSuccess: "Registration successful, please login", registerFailed: "Registration failed, please try again", + userDisabled: "User is disabled", + sessionExpired: "Session expired, please sign in again", + authFailedRelogin: "Authentication failed, please sign in again", + loginRetryHint: "Sign-in was not completed, please refresh and try again", + loggingInWait: "Signing in, please wait...", + retryLogin: "Retry sign in", pleaseInputAccount: "Please enter your account", pleaseInputPassword: "Please enter your password", pleaseInputUsername: "Please enter your username", @@ -96,6 +111,7 @@ const enUS = { updateSuccess: "Profile updated successfully", noUserInfo: "Could not retrieve user information", invalidEmail: "Please enter a valid email address", + invalidPhone: "Please enter a valid mainland China phone number", }, // Chat @@ -112,6 +128,8 @@ const enUS = { copy: "Copy", copySuccess: "Copied", regenerate: "Regenerate", + regenerateInputMissing: + "No question content was found for regeneration. Please ask again.", feedback: "Feedback", references: "References", noReferences: "No references", @@ -317,6 +335,8 @@ const enUS = { taskList: "Task List", taskDetail: "Task Detail", elapsedTime: "Elapsed Time", + knowledgeBaseName: "Knowledge Base Name", + inputKnowledgeBaseName: "Please enter a knowledge base name", // List page nameId: "Name / ID", docName: "Document Name", @@ -335,6 +355,7 @@ const enUS = { searchPlaceholder: "Name / Description / Tags", searchDocPlaceholder: "Search by name, tags, or updater", createKnowledgeBase: "Create Knowledge Base", + editKnowledgeBase: "Edit Knowledge Base", selectTag: "Select knowledge base tag", knowledge: "Knowledge", allTags: "All", @@ -391,6 +412,7 @@ const enUS = { selectGroupName: "Please select a group name", maxAddUsers: "You can add up to 20 users at a time", maxAddGroups: "You can add up to 20 groups at a time", + editOwnerPermissionDenied: "Cannot modify the creator's permission", deleteOwnerPermissionDenied: "Cannot delete the creator's permission", deletePermissionTitle: "Notice", deletePermissionContent: "Are you sure to delete the knowledge base {{role}} permission of {{type}} {{name}}?", @@ -411,7 +433,7 @@ const enUS = { uploadAndCreateTaskSuccess: "Upload and task creation succeeded", uploadFailedRetry: "Upload failed, please try again", supportedFolderImport: "Folder import supported", - supportedZipFile: "ZIP files are supported", + supportedZipFile: "Only ZIP archive files are supported (.zip)", supportedDocTypes: "PDF, DOCX and DOC files are supported", zipRootOnly: "ZIP archives only support files in the root directory; nested folders will be ignored", uploadLimitHint: "Up to 300 files per upload, each file must be under 500MB, total size under 1GB", @@ -578,6 +600,9 @@ const enUS = { deleteSuccess: "Deleted successfully", deleteFailed: "Delete failed", deleteUserConfirm: "Are you sure you want to delete this user?", + enableSuccess: "User enabled successfully", + enableFailed: "Failed to enable user", + enableUserConfirm: "Are you sure you want to enable this user?", disableSuccess: "User disabled successfully", disableFailed: "Failed to disable user", disableUserConfirm: "Are you sure you want to disable this user?", @@ -637,9 +662,11 @@ const enUS = { confirmPasswordWithMax: "Please re-enter password, up to {{max}} characters", selectRole: "Please select a role", selectRoleRequired: "Please select a role", + bootstrapAdminRoleLocked: "Bootstrap admin role cannot be changed", users: "Groups", backToApp: "Back to App" }, + errors: enUSErrorMessages, }; export default enUS; diff --git a/frontend/src/i18n/locales/error-codes.ts b/frontend/src/i18n/locales/error-codes.ts new file mode 100644 index 0000000..f7fc19e --- /dev/null +++ b/frontend/src/i18n/locales/error-codes.ts @@ -0,0 +1,233 @@ +export const zhCNErrorMessages = { + "2000201": "请求体无效", + "2000202": "JSON 请求体无效", + "2000203": "请求无效", + "2000204": "请求方法不允许", + "2000205": "缺少 X-User-Id", + "2000206": "缺少知识库", + "2000207": "缺少知识库或文档", + "2000208": "缺少知识库或 upload_file_id", + "2000209": "缺少路径", + "2000210": "路径编码无效", + "2000211": "路径无效", + "2000212": "缺少知识库或任务", + "2000213": "请求体无效", + "2000214": "请求体中的 task_id 与路径不一致", + "2000215": "缺少文件名", + "2000216": "file_size 必须大于等于 0", + "2000217": "part_size 必须大于等于 0", + "2000218": "缺少 items", + "2000219": "任务仅可从 FAILED 或 CANCELED 状态恢复", + "2000220": "打开上传文件失败", + "2000221": "缺少分段", + "2000222": "读取请求体失败", + "2000223": "search_config 无效(top_k 取值 1-10,confidence 取值 0-1)", + "2000224": "缺少 conversation_id", + "2000225": "角色无效", + "2000226": "user_id_list 和 group_id_list 不能同时为空", + "2000227": "未提供有效的 user_id_list 或 group_id_list", + "2000301": "缺少签名", + "2000302": "签名 URL 已过期", + "2000303": "签名无效", + "2000401": "文档不存在", + "2000402": "文档文件不存在", + "2000403": "上传文件不存在", + "2000404": "上传文件路径为空", + "2000405": "任务不存在", + "2000406": "提示词不存在", + "2000407": "会话不存在", + "2000408": "资源不存在", + "2000410": "分段不存在", + "2000411": "成员不存在", + "2000501": "查询文档失败", + "2000502": "创建文档失败", + "2000503": "更新文档失败", + "2000504": "搜索文档失败", + "2000505": "查询任务失败", + "2000506": "创建或获取会话失败", + "2000507": "存储未初始化", + "2000508": "不支持流式响应", + "2000509": "请求失败", + "2000510": "更新失败", + "2000511": "删除失败", + "2000512": "列表查询失败", + "2000513": "保存上传文件失败", + "2000514": "创建临时目录失败", + "2000515": "创建上传目标失败", + "2000516": "创建上传文件记录失败", + "2000517": "加载上传元数据失败", + "2000518": "创建上传分片失败", + "2000519": "写入上传分片失败", + "2000520": "ACL 存储未初始化", + "2000521": "添加知识库成员失败", + "2000522": "查询知识库失败", + "2000601": "名称过长", + "2000602": "内容过长", + "2000603": "缺少显示名称和内容", + "2000604": "缺少显示名称或内容", + "2000605": "提示词名称无效", + "2000606": "提示词已存在", + "2000607": "缺少 task_ids", + "2000608": "multipart 表单无效", + "2000609": "未上传任何文件", + "2000610": "未上传文件", + "2000611": "page_token 无效", + "2000612": "缺少 input", + "2000613": "缺少 query", + "2000614": "显示名称过长", + "2000615": "conversation_id 过长", + "2000616": "外部任务 ID 为空", + "2000701": "任务无法取消", + "2000702": "作业无法取消", + "2000710": "算法服务不可用", + "2000711": "算法服务返回错误", + "1000101": "用户名格式无效", + "1000102": "用户已存在", + "1000103": "密码格式无效", + "1000104": "登录已锁定,请稍后再试", + "1000105": "用户名或密码错误", + "1000106": "用户已被禁用", + "1000201": "缺少用户名", + "1000202": "缺少密码", + "1000203": "缺少 refresh_token", + "1000204": "两次输入的密码不一致", + "1000205": "旧密码错误", + "1000206": "缺少新密码", + "1000207": "refresh_token 无效或已过期", + "1000208": "新密码不能与旧密码相同", + "1000209": "手机号格式无效", + "1000301": "未认证", + "1000302": "无权限访问", + "1000303": "需要管理员权限", + "1000401": "用户不存在", + "1000402": "用户组不存在", + "1000403": "角色不存在", + "1000404": "缺少用户组名称", + "1000405": "用户组名称不能为空", + "1000406": "缺少角色", + "1000407": "成员关系不存在", + "1000408": "缺少角色名称", + "1000409": "角色名称已存在", + "1000410": "内置角色不允许删除", + "1000411": "system-admin 角色权限不允许修改", + "1000412": "初始化管理员角色不允许修改", + "1000601": "Redis 认证失败", + "1000602": "Redis 不可用", +} as const; + +export const enUSErrorMessages = { + "2000201": "Invalid request body", + "2000202": "Invalid JSON body", + "2000203": "Invalid request", + "2000204": "Method not allowed", + "2000205": "X-User-Id is required", + "2000206": "Dataset is required", + "2000207": "Dataset or document is required", + "2000208": "Dataset or upload_file_id is required", + "2000209": "Path is required", + "2000210": "Invalid path encoding", + "2000211": "Invalid path", + "2000212": "Dataset or task is required", + "2000213": "Invalid request body", + "2000214": "task_id in body does not match path", + "2000215": "Filename is required", + "2000216": "file_size must be >= 0", + "2000217": "part_size must be >= 0", + "2000218": "Items are required", + "2000219": "Task can only be resumed from FAILED or CANCELED state", + "2000220": "Failed to open upload file", + "2000221": "Segment is required", + "2000222": "Failed to read request body", + "2000223": "Invalid search_config", + "2000224": "conversation_id is required", + "2000225": "Invalid role", + "2000226": "user_id_list and group_id_list cannot both be empty", + "2000227": "No valid user_id_list or group_id_list provided", + "2000301": "Signature is required", + "2000302": "Signed URL has expired", + "2000303": "Invalid signature", + "2000401": "Document not found", + "2000402": "Document file not found", + "2000403": "Uploaded file not found", + "2000404": "Uploaded file path is empty", + "2000405": "Task not found", + "2000406": "Prompt not found", + "2000407": "Conversation not found", + "2000408": "Resource not found", + "2000410": "Segment not found", + "2000411": "Member not found", + "2000501": "Failed to query documents", + "2000502": "Failed to create document", + "2000503": "Failed to update document", + "2000504": "Failed to search documents", + "2000505": "Failed to query tasks", + "2000506": "Failed to ensure conversation", + "2000507": "Store is not initialized", + "2000508": "Streaming is not supported", + "2000509": "Request failed", + "2000510": "Update failed", + "2000511": "Delete failed", + "2000512": "List failed", + "2000513": "Failed to save upload file", + "2000514": "Failed to create temp dir", + "2000515": "Failed to create upload target", + "2000516": "Failed to create uploaded file", + "2000517": "Failed to load upload meta", + "2000518": "Failed to create upload part", + "2000519": "Failed to write upload part", + "2000520": "ACL store is not initialized", + "2000521": "Failed to add dataset members", + "2000522": "Failed to query datasets", + "2000601": "Name is too long", + "2000602": "Content is too long", + "2000603": "Display name and content are required", + "2000604": "Display name or content is required", + "2000605": "Invalid prompt name", + "2000606": "Prompt already exists", + "2000607": "task_ids is required", + "2000608": "Invalid multipart form", + "2000609": "No files uploaded", + "2000610": "No file uploaded", + "2000611": "Invalid page_token", + "2000612": "Input is required", + "2000613": "Query is required", + "2000614": "Display name is too long", + "2000615": "conversation_id is too long", + "2000616": "External task id is empty", + "2000701": "Task cannot be canceled", + "2000702": "Job cannot be canceled", + "2000710": "Algo service is unavailable", + "2000711": "Algo service returned an error", + "1000101": "Invalid username format", + "1000102": "User already exists", + "1000103": "Invalid password format", + "1000104": "Login is locked, please try again later", + "1000105": "Invalid username or password", + "1000106": "User is disabled", + "1000201": "Username is required", + "1000202": "Password is required", + "1000203": "refresh_token is required", + "1000204": "Password confirmation does not match", + "1000205": "Old password is incorrect", + "1000206": "New password is required", + "1000207": "refresh_token is invalid or expired", + "1000208": "New password must be different from old password", + "1000209": "Invalid phone format", + "1000301": "Unauthorized", + "1000302": "Forbidden", + "1000303": "Admin permission is required", + "1000401": "User not found", + "1000402": "Group not found", + "1000403": "Role not found", + "1000404": "Group name is required", + "1000405": "Group name cannot be empty", + "1000406": "Role is required", + "1000407": "Membership not found", + "1000408": "Role name is required", + "1000409": "Role name already exists", + "1000410": "Built-in role cannot be deleted", + "1000411": "System-admin role permissions cannot be changed", + "1000412": "Bootstrap admin role cannot be changed", + "1000601": "Redis authentication failed", + "1000602": "Redis is unavailable", +} as const; diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index c8500e5..6915e85 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1,3 +1,5 @@ +import { zhCNErrorMessages } from "./error-codes"; + const zhCN = { common: { search: "搜索", @@ -11,6 +13,7 @@ const zhCN = { loading: "加载中...", noData: "暂无数据", success: "操作成功", + saveSuccess: "保存成功", failed: "操作失败", sortBy: "按", sort: "排序", @@ -18,6 +21,12 @@ const zhCN = { description: "描述", actions: "操作", totalItems: "共 {{total}} 条", + itemsPerPageSuffix: "条/页", + pageSize: "每页条数", + accessDenied: "访问被拒绝", + requestFailed: "请求失败", + serverNoResponse: "服务器无响应", + requestError: "请求发生错误", }, auth: { @@ -40,6 +49,12 @@ const zhCN = { loginFailed: "登录失败,请检查账号密码", registerSuccess: "注册成功,请登录", registerFailed: "注册失败,请重试", + userDisabled: "用户已被禁用", + sessionExpired: "登录状态已失效,请重新登录", + authFailedRelogin: "认证失败,请重新登录", + loginRetryHint: "未成功登录,请刷新页面后重试", + loggingInWait: "登录中,请稍等...", + retryLogin: "重试登录", pleaseInputAccount: "请输入登录账号", pleaseInputPassword: "请输入登录密码", pleaseInputUsername: "请输入用户名", @@ -92,6 +107,7 @@ const zhCN = { updateSuccess: "账号信息已更新", noUserInfo: "未获取到当前用户信息", invalidEmail: "请输入有效的邮箱格式", + invalidPhone: "请输入有效的中国大陆手机号", }, chat: { @@ -107,6 +123,7 @@ const zhCN = { copy: "复制", copySuccess: "复制成功", regenerate: "重新生成", + regenerateInputMissing: "未找到可重新生成的问题内容,请重新提问", feedback: "反馈", references: "参考来源", noReferences: "暂无参考来源", @@ -306,6 +323,8 @@ const zhCN = { taskList: "任务列表", taskDetail: "任务详情", elapsedTime: "耗时", + knowledgeBaseName: "知识库名称", + inputKnowledgeBaseName: "请输入知识库名称", nameId: "知识库名称/ID", docName: "知识名称", updateDate: "更新日期", @@ -323,6 +342,7 @@ const zhCN = { searchPlaceholder: "知识库名称/描述/标签", searchDocPlaceholder: "搜索文档名称、标签、更新人", createKnowledgeBase: "创建知识库", + editKnowledgeBase: "编辑知识库", selectTag: "请选择知识库标签", knowledge: "知识", allTags: "全部", @@ -379,6 +399,7 @@ const zhCN = { selectGroupName: "请选择用户组名称", maxAddUsers: "最多同时添加 20 个用户", maxAddGroups: "最多同时添加 20 个用户组", + editOwnerPermissionDenied: "无法修改创建者权限", deleteOwnerPermissionDenied: "无法删除创建者权限", deletePermissionTitle: "提示", deletePermissionContent: "确定删除{{type}} {{name}} 的知识库 {{role}} 相应权限?", @@ -399,7 +420,7 @@ const zhCN = { uploadAndCreateTaskSuccess: "上传并创建任务成功", uploadFailedRetry: "上传失败,请重试", supportedFolderImport: "支持导入文件夹", - supportedZipFile: "支持zip类型的文件", + supportedZipFile: "仅支持 ZIP 类型压缩包文件(.zip)", supportedDocTypes: "支持pdf、docx、doc类型的文件", zipRootOnly: "zip压缩包仅支持根目录的文件,一级及以上文件夹将被忽略", uploadLimitHint: "单次上传限制300个文件,单个文件大小不超过500MB,总大小不超过1GB", @@ -564,6 +585,9 @@ const zhCN = { deleteSuccess: "删除成功", deleteFailed: "删除失败", deleteUserConfirm: "确定删除该用户吗?", + enableSuccess: "启用成功", + enableFailed: "启用失败", + enableUserConfirm: "确定启用该用户吗?", disableSuccess: "禁用成功", disableFailed: "禁用失败", disableUserConfirm: "确定禁用该用户吗?", @@ -623,9 +647,11 @@ const zhCN = { confirmPasswordWithMax: "请再次输入密码,最多 {{max}} 个字符", selectRole: "请选择角色", selectRoleRequired: "请选择角色", + bootstrapAdminRoleLocked: "初始化管理员角色不允许修改", users: "用户组", backToApp: "返回应用平台" }, + errors: zhCNErrorMessages, }; export default zhCN; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index eb73577..d83a199 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -7,6 +7,7 @@ import { MessageFilled, AppstoreOutlined, TeamOutlined, + GlobalOutlined, } from "@ant-design/icons"; import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom"; import type { UserDetailResponse } from "@/api/generated/auth-client"; @@ -23,6 +24,7 @@ import LanguageSwitcher from "@/components/LanguageSwitcher"; import "./index.scss"; const { Content, Sider } = Layout; +const MAINLAND_CHINA_PHONE_REGEX = /^1[3-9]\d{9}$/; type MenuItem = Required["items"][number]; @@ -179,6 +181,33 @@ export default function MainLayout() { }, }); + const phoneRule = { + validator(_: any, value?: string) { + const phone = normalizeFieldValue(value); + if (!phone || MAINLAND_CHINA_PHONE_REGEX.test(phone)) { + return Promise.resolve(); + } + return Promise.reject(new Error(t("profile.invalidPhone"))); + }, + }; + + const clearPasswordFields = () => { + profileForm.setFieldsValue({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + }; + + const schedulePasswordFieldClear = () => { + window.setTimeout(() => { + clearPasswordFields(); + }, 0); + window.setTimeout(() => { + clearPasswordFields(); + }, 300); + }; + const applyProfileToForm = (detail: UserDetailResponse) => { profileForm.setFieldsValue({ username: detail.username, @@ -188,10 +217,8 @@ export default function MainLayout() { remark: (detail as any).remark || "", roleName: detail.role_name || "", status: detail.status || "", - currentPassword: "", - newPassword: "", - confirmPassword: "", }); + clearPasswordFields(); }; const refreshCurrentProfile = async () => { @@ -299,11 +326,7 @@ export default function MainLayout() { {logoSrc ? ( logo ) : ( - logo + logo )}
-
+
+
{ + if (open) { + schedulePasswordFieldClear(); + } + }} >
@@ -426,8 +456,17 @@ export default function MainLayout() { > - - + + @@ -445,7 +484,8 @@ export default function MainLayout() { > diff --git a/frontend/src/layouts/index.scss b/frontend/src/layouts/index.scss index 048a188..7719748 100644 --- a/frontend/src/layouts/index.scss +++ b/frontend/src/layouts/index.scss @@ -33,15 +33,15 @@ } .img-box { - min-height: 62px; + min-height: 84px; display: flex; justify-content: center; align-items: center; - padding: 8px 8px 18px; + padding: 10px 8px 12px; img { - width: 170px; - height: 40px; + width: 188px; + max-height: 64px; max-width: 100%; object-fit: contain; } @@ -164,6 +164,13 @@ font-size: 18px; color: inherit; } + + .language-item { + .language-switcher { + flex: 1; + min-width: 0; + } + } } .settings-popover { diff --git a/frontend/src/modules/admin/AdminLayout.tsx b/frontend/src/modules/admin/AdminLayout.tsx index 581d0e0..53f0581 100644 --- a/frontend/src/modules/admin/AdminLayout.tsx +++ b/frontend/src/modules/admin/AdminLayout.tsx @@ -63,6 +63,9 @@ export default function AdminLayout() { children: menuChildren, }, ]; + const logoSrc = + (import.meta.env as ImportMetaEnv & { VITE_APP_LOGO?: string }) + .VITE_APP_LOGO || ""; const onMenuClick: MenuProps["onClick"] = ({ key }) => { if (String(key).startsWith("/admin/")) { @@ -82,7 +85,11 @@ export default function AdminLayout() {
- logo + logo
([]); const [leftSearch, setLeftSearch] = useState(""); const [rightSearch, setRightSearch] = useState(""); + const isUserInactive = (status?: string) => + status?.toLowerCase() === "inactive"; const fetchAllUsers = useCallback(async () => { if (!isAdmin) return; @@ -225,8 +229,8 @@ const ManageMembersModal = ({ }, [pendingAddUsers, rightSearch]); const moveToRight = () => { - const usersToMove = leftDataSource.filter((u) => - leftSelectedKeys.includes(u.user_id), + const usersToMove = leftDataSource.filter( + (u) => leftSelectedKeys.includes(u.user_id) && !isUserInactive(u.status), ); setPendingAddUsers((prev) => [...prev, ...usersToMove]); setLeftSelectedKeys([]); @@ -258,9 +262,12 @@ const ManageMembersModal = ({ onSuccess?.(); } catch (error: any) { console.error("Add members failed:", error); - AntdMessage.error( - error.response?.data?.message || t("admin.addMembersFailed"), - ); + if (!error?.response && !error?.request) { + AntdMessage.error( + getLocalizedErrorMessage(error, t("admin.addMembersFailed")) || + t("admin.addMembersFailed"), + ); + } } finally { setSaving(false); } @@ -368,6 +375,9 @@ const ManageMembersModal = ({ rowSelection={{ selectedRowKeys: leftSelectedKeys, onChange: (keys) => setLeftSelectedKeys(keys as string[]), + getCheckboxProps: (record) => ({ + disabled: isUserInactive(record.status), + }), }} dataSource={leftDataSource} columns={userColumns} @@ -375,11 +385,11 @@ const ManageMembersModal = ({ loading={loading} tableLayout="fixed" scroll={{ x: 620 }} - pagination={{ + pagination={getLocalizedTablePagination({ size: "small", pageSize: 10, showSizeChanger: false, - }} + }, t)} />
@@ -421,6 +431,9 @@ const ManageMembersModal = ({ rowSelection={{ selectedRowKeys: rightSelectedKeys, onChange: (keys) => setRightSelectedKeys(keys as string[]), + getCheckboxProps: (record) => ({ + disabled: isUserInactive(record.status), + }), }} dataSource={rightDataSource} columns={userColumns.slice(0, 1)} diff --git a/frontend/src/modules/admin/pages/group/detail.tsx b/frontend/src/modules/admin/pages/group/detail.tsx index 9e595dd..d945cdf 100644 --- a/frontend/src/modules/admin/pages/group/detail.tsx +++ b/frontend/src/modules/admin/pages/group/detail.tsx @@ -12,6 +12,7 @@ import { createGroupApi } from "@/modules/signin/utils/request"; import type { GroupDetailResponse, GroupUserItem } from "@/api/generated/auth-client"; import { AgentAppsAuth } from "@/components/auth"; import DetailPageHeader from "@/components/ui/DetailPageHeader"; +import { getLocalizedTablePagination } from "@/components/ui/pagination"; import ManageMembersModal from "./components/ManageMembersModal"; import CreateGroupModal from "./components/CreateGroupModal"; @@ -22,6 +23,8 @@ const breakTextStyle: CSSProperties = { wordBreak: "break-word", }; +const MEMBER_TABLE_SCROLL_Y = 360; + const GroupDetail = () => { const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); @@ -124,11 +127,13 @@ const GroupDetail = () => { title: t("admin.username"), dataIndex: "username", key: "username", + width: 220, }, { title: t("admin.remark"), dataIndex: "remark", key: "remark", + width: 260, render: (text: string) => ( {text || "-"} @@ -139,6 +144,7 @@ const GroupDetail = () => { title: t("admin.role"), dataIndex: "role", key: "role", + width: 140, render: (role: string) => ( {role === "admin" ? t("admin.groupAdmin") : t("admin.member")} @@ -149,12 +155,13 @@ const GroupDetail = () => { title: t("admin.joinedAt"), dataIndex: "created_at", key: "created_at", + width: 220, render: (text: string) => text || "-", }, { title: t("admin.actions"), key: "action", - width: 200, + width: 180, render: (_: any, record: GroupUserItem) => ( {} @@ -245,11 +252,17 @@ const GroupDetail = () => { )}
t("common.totalItems", { total }) }} + tableLayout="fixed" + scroll={{ x: 1020, y: MEMBER_TABLE_SCROLL_Y }} + pagination={getLocalizedTablePagination( + { showSizeChanger: true, showTotal: (total) => t("common.totalItems", { total }) }, + t, + )} /> diff --git a/frontend/src/modules/admin/pages/group/index.tsx b/frontend/src/modules/admin/pages/group/index.tsx index 171fa3c..2882b1f 100644 --- a/frontend/src/modules/admin/pages/group/index.tsx +++ b/frontend/src/modules/admin/pages/group/index.tsx @@ -11,9 +11,10 @@ import { import CreateGroupModal from "./components/CreateGroupModal"; import ManageMembersModal from "./components/ManageMembersModal"; import ManagePermissionsModal from "./components/ManagePermissionsModal"; -import { createGroupApi, createUsersServiceApi } from "@/modules/signin/utils/request"; +import { createGroupApi } from "@/modules/signin/utils/request"; import { AgentAppsAuth } from "@/components/auth"; import type { GroupItem } from "@/api/generated/auth-client"; +import { getLocalizedTablePagination } from "@/components/ui/pagination"; const { Paragraph } = Typography; const NAME_COLUMN_WIDTH = 220; @@ -38,7 +39,6 @@ const GroupManagement = () => { const [selectedGroupForPermissions, setSelectedGroupForPermissions] = useState(null); const [searchTerm, setSearchTerm] = useState(""); - const [applyingGroupId, setApplyingGroupId] = useState(null); const userInfo = AgentAppsAuth.getUserInfo(); const isAdmin = (role?: string) => { @@ -117,19 +117,6 @@ const GroupManagement = () => { setIsPermissionModalVisible(true); }; - const handleApplyJoinGroup = async (group: GroupItem) => { - setApplyingGroupId(group.group_id); - try { - const api = createUsersServiceApi(); - await api.userApplyToJoinGroups({ groupId: group.group_id }); - message.success(t("admin.applyJoinGroupSuccess", { groupName: group.group_name })); - } catch (error) { - console.error("Failed to apply join group:", error); - } finally { - setApplyingGroupId(null); - } - }; - const renderEllipsisText = (text?: string, emptyText = "-") => { if (!text) { return emptyText; @@ -200,10 +187,10 @@ const GroupManagement = () => { { title: t("admin.actions"), key: "action", - width: isUserAdmin ? 200 : 140, + width: 200, render: (_: any, record: GroupItem) => ( - {isUserAdmin ? ( + {isUserAdmin && ( <> - ) : ( - )} ), @@ -298,11 +275,11 @@ const GroupManagement = () => { loading={loading} tableLayout="fixed" scroll={{ x: 980 }} - pagination={{ + pagination={getLocalizedTablePagination({ ...pagination, showSizeChanger: true, showTotal: (total) => t("common.totalItems", { total }), - }} + }, t)} onChange={handleTableChange} /> diff --git a/frontend/src/modules/admin/pages/user/components/CreateUserModal.tsx b/frontend/src/modules/admin/pages/user/components/CreateUserModal.tsx index 934d3b9..ffa770c 100644 --- a/frontend/src/modules/admin/pages/user/components/CreateUserModal.tsx +++ b/frontend/src/modules/admin/pages/user/components/CreateUserModal.tsx @@ -1,6 +1,7 @@ import { Modal, Form, Input, Select, message } from "antd"; import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { getLocalizedErrorMessage } from "@/components/request"; import { createUserApi, createRoleApi } from "@/modules/signin/utils/request"; import { passwordRules, @@ -12,9 +13,13 @@ const USERNAME_MAX_LENGTH = 100; const EMAIL_MAX_LENGTH = 100; const PASSWORD_MAX_LENGTH = 32; +type EditableUserItem = UserItem & { + is_bootstrap_admin?: boolean; +}; + interface CreateUserModalProps { visible: boolean; - editingUser?: UserItem | null; + editingUser?: EditableUserItem | null; onCancel: () => void; onSuccess: () => void; } @@ -67,6 +72,11 @@ const CreateUserModal = ({ visible, editingUser, onCancel, onSuccess }: CreateUs }, [visible, editingUser, form]); const onFinish = async (values: any) => { + if (editingUser?.is_bootstrap_admin) { + message.warning(t("admin.bootstrapAdminRoleLocked")); + return; + } + setLoading(true); try { const userApi = createUserApi(); @@ -90,8 +100,12 @@ const CreateUserModal = ({ visible, editingUser, onCancel, onSuccess }: CreateUs onSuccess(); } catch (error: any) { console.error("Operation failed:", error); - const errorMsg = error.response?.data?.message || error.message || t("common.failed"); - message.error(errorMsg); + if (!error?.response && !error?.request) { + message.error( + getLocalizedErrorMessage(error, t("common.failed")) || + t("common.failed"), + ); + } } finally { setLoading(false); } @@ -104,6 +118,7 @@ const CreateUserModal = ({ visible, editingUser, onCancel, onSuccess }: CreateUs onCancel={onCancel} onOk={() => form.submit()} confirmLoading={loading} + okButtonProps={{ disabled: !!editingUser?.is_bootstrap_admin }} destroyOnHidden >
- {roles.map((role: any) => ( {role.name} diff --git a/frontend/src/modules/admin/pages/user/index.tsx b/frontend/src/modules/admin/pages/user/index.tsx index 61578ab..f6c62d2 100644 --- a/frontend/src/modules/admin/pages/user/index.tsx +++ b/frontend/src/modules/admin/pages/user/index.tsx @@ -1,22 +1,70 @@ import { useState, useEffect, useCallback } from "react"; import { Table, Button, Space, Tag, Popconfirm, message, Modal, Form, Input, Tooltip } from "antd"; -import { PlusOutlined, StopOutlined, EditOutlined, KeyOutlined } from "@ant-design/icons"; +import { + PlusOutlined, + StopOutlined, + CheckCircleOutlined, + EditOutlined, + KeyOutlined, +} from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import CreateUserModal from "./components/CreateUserModal"; import { createUserApi } from "@/modules/signin/utils/request"; import { validatePassword } from "@/modules/signin/utils/formRules"; import type { UserItem } from "@/api/generated/auth-client"; +import { getLocalizedTablePagination } from "@/components/ui/pagination"; const PASSWORD_MAX_LENGTH = 32; const USERNAME_COLUMN_WIDTH = 220; +type AdminUserItem = UserItem & { + is_bootstrap_admin?: boolean; +}; + +type RawUserItem = Partial & { + id?: string | number; + userId?: string | number; + roleId?: string | number; + roleName?: string; + status_text?: string; + disabled?: boolean; + role?: string | { id?: string | number; name?: string }; +}; + +const resolveUserId = (user?: RawUserItem | null) => { + const candidate = user?.user_id ?? user?.userId ?? user?.id; + if (candidate === undefined || candidate === null || candidate === "") { + return ""; + } + return String(candidate); +}; + +const normalizeUserItem = (user: RawUserItem): AdminUserItem => { + const role = + user.role && typeof user.role === "object" ? user.role : undefined; + const statusFromDisabled = + typeof user.disabled === "boolean" + ? user.disabled + ? "disabled" + : "active" + : undefined; + + return { + ...user, + user_id: resolveUserId(user), + role_id: String(user.role_id ?? user.roleId ?? role?.id ?? ""), + role_name: user.role_name ?? user.roleName ?? role?.name ?? String(user.role ?? ""), + status: user.status ?? user.status_text ?? statusFromDisabled ?? "active", + } as AdminUserItem; +}; + const UserManagement = () => { const { t } = useTranslation(); const [isModalVisible, setIsModalVisible] = useState(false); const [loading, setLoading] = useState(false); - const [users, setUsers] = useState([]); + const [users, setUsers] = useState([]); const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 }); - const [editingUser, setEditingUser] = useState(null); + const [editingUser, setEditingUser] = useState(null); const [resetPasswordForm] = Form.useForm(); const [searchTerm, setSearchTerm] = useState(""); @@ -31,12 +79,20 @@ const UserManagement = () => { }); const resData = res.data as any; const data = resData.data || resData; + const rawUsers = Array.isArray(data?.users) + ? data.users + : Array.isArray(data) + ? data + : []; + const normalizedUsers = rawUsers.map((item: RawUserItem) => + normalizeUserItem(item), + ); - setUsers(data.users || []); + setUsers(normalizedUsers); setPagination({ current: Number(data.page || page), pageSize: Number(data.page_size || pageSize), - total: Number(data.total || 0), + total: Number(data.total || normalizedUsers.length || 0), }); } catch (error) { console.error("Failed to fetch users:", error); @@ -55,32 +111,58 @@ const UserManagement = () => { fetchUsers(1, pagination.pageSize, value); }; - const isUserDisabled = (status?: string) => - status !== "active" && status !== "enabled"; + const isUserDisabled = (status?: string) => { + const normalizedStatus = status?.toLowerCase(); + return !["active", "enabled", "normal"].includes(normalizedStatus || ""); + }; + + const handleDisable = async (user: RawUserItem) => { + await handleToggleUserStatus(user, true); + }; + + const handleEnable = async (user: RawUserItem) => { + await handleToggleUserStatus(user, false); + }; + + const handleToggleUserStatus = async (user: RawUserItem, disabled: boolean) => { + const userId = resolveUserId(user); + + if (!userId) { + message.error( + disabled ? t("admin.disableFailed") : t("admin.enableFailed"), + ); + console.error("Toggle user status skipped: missing user id", user); + return; + } - const handleDisable = async (userId: string) => { try { const api = createUserApi(); await api.disableUserApiAuthserviceUserUserIdDisablePatch({ userId, disableUserBody: { - disabled: true, + disabled, }, }); - message.success(t("admin.disableSuccess")); + message.success( + disabled ? t("admin.disableSuccess") : t("admin.enableSuccess"), + ); fetchUsers(pagination.current, pagination.pageSize, searchTerm); } catch (error) { - console.error("Disable user failed:", error); - message.error(t("admin.disableFailed")); + console.error("Toggle user status failed:", error); + message.error( + disabled ? t("admin.disableFailed") : t("admin.enableFailed"), + ); } }; - const handleEditRole = (user: UserItem) => { + const handleEditRole = (user: AdminUserItem) => { setEditingUser(user); setIsModalVisible(true); }; const handleResetPassword = (user: UserItem) => { + const userId = resolveUserId(user); + Modal.confirm({ title: t("admin.resetUserPasswordTitle", { username: user.username }), content: ( @@ -103,12 +185,17 @@ const UserManagement = () => { ), + okText: t("common.confirm"), + cancelText: t("common.cancel"), onOk: async () => { try { + if (!userId) { + throw new Error("Missing user id"); + } const values = await resetPasswordForm.validateFields(); const api = createUserApi(); await api.resetPasswordApiAuthserviceUserUserIdResetPasswordPatch({ - userId: user.user_id, + userId, resetPasswordBody: { new_password: values.new_password }, }); message.success(t("admin.resetPasswordSuccess")); @@ -182,19 +269,30 @@ const UserManagement = () => { key: "action", fixed: 'right' as const, width: 240, - render: (_: any, record: UserItem) => { + render: (_: any, record: AdminUserItem) => { const disabled = isUserDisabled(record.status); - - return ( - - + ); + + return ( + + {isBootstrapAdmin ? ( + + {editRoleButton} + + ) : ( + editRoleButton + )} handleDisable(record.user_id)} + title={ + disabled + ? t("admin.enableUserConfirm") + : t("admin.disableUserConfirm") + } + onConfirm={() => + disabled + ? handleEnable(record) + : handleDisable(record) + } okText={t("common.confirm")} cancelText={t("common.cancel")} - disabled={disabled} > @@ -265,16 +369,16 @@ const UserManagement = () => { className="admin-page-table" columns={columns} dataSource={users} - rowKey="user_id" + rowKey={(record) => resolveUserId(record) || record.username} loading={loading} tableLayout="fixed" scroll={{ x: 800 }} - pagination={{ + pagination={getLocalizedTablePagination({ ...pagination, showSizeChanger: true, showQuickJumper: true, showTotal: (total) => t("common.totalItems", { total }), - }} + }, t)} onChange={handleTableChange} /> diff --git a/frontend/src/modules/chat/components/ChatContainer/index.tsx b/frontend/src/modules/chat/components/ChatContainer/index.tsx index 00753cd..92c9dc6 100644 --- a/frontend/src/modules/chat/components/ChatContainer/index.tsx +++ b/frontend/src/modules/chat/components/ChatContainer/index.tsx @@ -6,7 +6,7 @@ import { useImperativeHandle, ReactElement, } from "react"; -import { Button, Spin, Input, Flex, Badge } from "antd"; +import { Button, Spin, Input, Flex, Badge, message } from "antd"; import { PlusSquareOutlined, SendOutlined, @@ -40,6 +40,7 @@ import { streamManager } from "@/modules/chat/utils/StreamManager"; import { ChatServiceApi } from "@/modules/chat/utils/request"; import dayjs from "dayjs"; import { useTranslation } from "react-i18next"; +import { getRegenerationInputs } from "@/modules/chat/utils/message"; const ThinkIcon = new URL("../../assets/images/think.png", import.meta.url) .href; @@ -846,20 +847,26 @@ const ChatContainerComponent = forwardRef( if (loading) { return; } + const userMessage = messageListRef.current.findLast( + (item: any) => item.role === RoleTypes.USER, + ); + const regenerationInputs = getRegenerationInputs(userMessage); + if (regenerationInputs.length < 1) { + message.error(t("chat.regenerateInputMissing")); + return; + } const assistantMessage = { role: RoleTypes.ASSISTANT, finish_reason: ChatConversationsResponseFinishReasonEnum.FinishReasonUnspecified, }; - const newList = [...messageList]; + const newList = [...messageListRef.current]; newList[newList.length - 1] = assistantMessage; + messageListRef.current = newList; setMessageList(newList); - const userMessage = messageList.findLast( - (item: any) => item.role === RoleTypes.USER, - ); isMouseScrollingRef.current = true; openSSE( - userMessage?.inputs, + regenerationInputs, ChatConversationsRequestActionEnum.ChatActionRegeneration, ); } diff --git a/frontend/src/modules/chat/components/ChatSelector/index.scss b/frontend/src/modules/chat/components/ChatSelector/index.scss index c804003..17dd473 100644 --- a/frontend/src/modules/chat/components/ChatSelector/index.scss +++ b/frontend/src/modules/chat/components/ChatSelector/index.scss @@ -74,32 +74,25 @@ } .chat-selector-container { - width: 100%; + width: 680px; + max-width: calc(100vw - 32px); background: white; border-radius: 8px; overflow: hidden; padding-top: 16px; - - .chat-selectot-config { - background-color: #fafafa; - padding: 0 16px; - .chat-select-config-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 0; - font-size: 14px; - color: #333; - } - } } .chat-selector-search-box { padding: 4px 16px; display: flex; align-items: center; + flex-wrap: wrap; + column-gap: 16px; + row-gap: 8px; .chat-selector-search-input { + flex: 1 1 280px; + min-width: 0; font-size: 14px; :global(.ant-input) { diff --git a/frontend/src/modules/chat/components/ChatSelector/index.tsx b/frontend/src/modules/chat/components/ChatSelector/index.tsx index 35dee06..a411474 100644 --- a/frontend/src/modules/chat/components/ChatSelector/index.tsx +++ b/frontend/src/modules/chat/components/ChatSelector/index.tsx @@ -1,12 +1,9 @@ -import { Button, Form, Input, Popover, Select, Space, Tag } from "antd"; +import { Button, Input, Popover } from "antd"; import { SearchOutlined, CheckOutlined, PushpinOutlined, PushpinFilled, - SettingOutlined, - DownOutlined, - UpOutlined, } from "@ant-design/icons"; import { useEffect, @@ -17,10 +14,9 @@ import { useRef, } from "react"; import { - DocumentServiceApi, KnowledgeBaseServiceApi, } from "@/modules/chat/utils/request"; -import { Dataset, UserInfo } from "@/api/generated/knowledge-client"; +import { Dataset } from "@/api/generated/knowledge-client"; import KnowledgeIcon from "../../assets/icons/knowledge.svg?react"; import "./index.scss"; import { debounce } from "lodash"; @@ -44,7 +40,6 @@ export interface ChatSelectorImperativeProps { const ChatSelector = forwardRef( (props, ref) => { const { chatConfig, onChange } = props; - const [form] = Form.useForm(); const { t } = useTranslation(); const [knowledgeBaseList, setKnowledgeBaseList] = useState([]); @@ -53,14 +48,33 @@ const ChatSelector = forwardRef( const [open, setOpen] = useState(false); const [knowledgeLoading, setKnowledgeLoading] = useState(false); const [defaultKnowledgeId, setDefaultKnowledgeId] = useState([]); - const [creators, setCreators] = useState([]); - const [tags, setTags] = useState([]); - const [showConfig, setShowConfig] = useState(false); const [searchValue, setSearchValue] = useState(""); const isResettingSelectionRef = useRef(false); + const isUpdatingDefaultRef = useRef(false); + const selectedIdsRef = useRef([]); useEffect(() => { - if (isResettingSelectionRef.current) { + selectedIdsRef.current = selectedIds; + }, [selectedIds]); + + function getDefaultDatasetIds(datasets: Dataset[]) { + return (datasets + ?.filter((it) => it?.default_dataset) + ?.map((k) => k.dataset_id) + .filter(Boolean) as string[]) || []; + } + + function mergeSelectedIds(...groups: Array>) { + return [ + ...new Set(groups.flat().filter((id): id is string => Boolean(id))), + ]; + } + + useEffect(() => { + if ( + isResettingSelectionRef.current || + isUpdatingDefaultRef.current + ) { return; } const setData = new Set([ @@ -68,12 +82,24 @@ const ChatSelector = forwardRef( ...(chatConfig?.knowledgeBaseId || []), ]); setSelectedIds([...setData]); - form.setFieldsValue({ - creators: chatConfig?.creators || [], - tags: chatConfig?.tags || [], - }); }, [chatConfig, defaultKnowledgeId]); + useEffect(() => { + const hasDocumentFilters = + (chatConfig?.creators?.length ?? 0) > 0 || + (chatConfig?.tags?.length ?? 0) > 0; + + if (!hasDocumentFilters) { + return; + } + + onChange?.( + mergeSelectedIds(defaultKnowledgeId, chatConfig?.knowledgeBaseId ?? []), + [], + [], + ); + }, [chatConfig, defaultKnowledgeId, onChange]); + useImperativeHandle(ref, () => ({ open: () => { setOpen(true); @@ -83,26 +109,8 @@ const ChatSelector = forwardRef( useEffect(() => { getKnowledgeBaseList(); - fetchCreators(); - fetchTags(); }, []); - function fetchCreators() { - DocumentServiceApi() - .documentServiceAllDocumentCreators() - .then((res) => { - setCreators(res.data.creators || []); - }); - } - - function fetchTags() { - DocumentServiceApi() - .documentServiceAllDocumentTags() - .then((res) => { - setTags(res.data.tags || []); - }); - } - function getKnowledgeBaseList() { setKnowledgeLoading(true); KnowledgeBaseServiceApi() @@ -111,13 +119,12 @@ const ChatSelector = forwardRef( const datasets = res.data.datasets || []; setKnowledgeBaseList(datasets); setFilteredList(datasets); - const defaultIds = datasets - ?.filter((it) => it?.default_dataset) - ?.map((k) => k.dataset_id) as string[]; + const defaultIds = getDefaultDatasetIds(datasets); setDefaultKnowledgeId(defaultIds); - const mergedIds = [ - ...new Set([...defaultIds, ...(chatConfig?.knowledgeBaseId ?? [])]), - ]; + const mergedIds = mergeSelectedIds( + defaultIds, + chatConfig?.knowledgeBaseId ?? [], + ); setSelectedIds(mergedIds); if ( defaultIds.length > 0 && @@ -126,20 +133,54 @@ const ChatSelector = forwardRef( ) { onChange?.( mergedIds, - chatConfig?.creators || [], - chatConfig?.tags || [], + [], + [], ); } }) .finally(() => setKnowledgeLoading(false)); } + function refreshKnowledgeBaseListPreservingSelection() { + isUpdatingDefaultRef.current = true; + setKnowledgeLoading(true); + KnowledgeBaseServiceApi() + .datasetServiceListDatasets({ pageSize: 1000 }) + .then((res) => { + const datasets = res.data.datasets || []; + setKnowledgeBaseList(datasets); + setFilteredList(datasets); + const defaultIds = getDefaultDatasetIds(datasets); + setDefaultKnowledgeId(defaultIds); + + const mergedIds = mergeSelectedIds( + defaultIds, + selectedIdsRef.current, + ); + setSelectedIds(mergedIds); + onChange?.( + mergedIds, + [], + [], + ); + }) + .finally(() => { + setKnowledgeLoading(false); + window.setTimeout(() => { + isUpdatingDefaultRef.current = false; + }, 0); + }); + } + const filterKnowledgeBaseListFn = debounce((search: string) => { setSearchValue(search); }, 300); const sortedAndFilteredList = useMemo(() => { let list = [...knowledgeBaseList]; + const originalIndexMap = new Map( + knowledgeBaseList.map((item, index) => [item.dataset_id || `idx-${index}`, index]), + ); if (searchValue.trim()) { list = list.filter((item) => @@ -148,8 +189,19 @@ const ChatSelector = forwardRef( } list.sort((a, b) => { + const aDefault = !!a.default_dataset; + const bDefault = !!b.default_dataset; const aSelected = selectedIds.includes(a.dataset_id || ""); const bSelected = selectedIds.includes(b.dataset_id || ""); + const aIndex = originalIndexMap.get(a.dataset_id || "") ?? 0; + const bIndex = originalIndexMap.get(b.dataset_id || "") ?? 0; + + if (aDefault && !bDefault) { + return -1; + } + if (!aDefault && bDefault) { + return 1; + } if (aSelected && !bSelected) { return -1; @@ -158,7 +210,7 @@ const ChatSelector = forwardRef( return 1; } - return 0; + return aIndex - bIndex; }); return list; @@ -168,11 +220,29 @@ const ChatSelector = forwardRef( setFilteredList(sortedAndFilteredList); }, [sortedAndFilteredList]); - function handleItemClick(datasetId?: string) { + function handleItemClick(item: Dataset) { + const datasetId = item.dataset_id; if (!datasetId) { return; } + // Default knowledge bases should stay selected until the pin is removed. + if (item.default_dataset) { + const mergedIds = mergeSelectedIds( + defaultKnowledgeId, + selectedIdsRef.current, + ); + if (mergedIds.length !== selectedIdsRef.current.length) { + setSelectedIds(mergedIds); + onChange?.( + mergedIds, + [], + [], + ); + } + return; + } + const newSelectedIds = selectedIds.includes(datasetId) ? selectedIds.filter((id) => id !== datasetId) : [...selectedIds, datasetId]; @@ -180,8 +250,8 @@ const ChatSelector = forwardRef( setSelectedIds(newSelectedIds); onChange?.( newSelectedIds, - form.getFieldValue("creators"), - form.getFieldValue("tags"), + [], + [], ); } @@ -192,7 +262,7 @@ const ChatSelector = forwardRef( unsetDefaultDatasetRequest: { name: item?.name ?? "" }, }) .then(() => { - getKnowledgeBaseList(); + refreshKnowledgeBaseListPreservingSelection(); }); } @@ -203,7 +273,7 @@ const ChatSelector = forwardRef( setDefaultDatasetRequest: { name: item?.name ?? "" }, }) .then(() => { - getKnowledgeBaseList(); + refreshKnowledgeBaseListPreservingSelection(); }); } @@ -275,8 +345,8 @@ const ChatSelector = forwardRef( setSelectedIds(defaultIds); onChange?.( defaultIds, - form.getFieldValue("creators") || [], - form.getFieldValue("tags") || [], + [], + [], ); }) .finally(() => { @@ -299,8 +369,8 @@ const ChatSelector = forwardRef( setSelectedIds(allIds); onChange?.( allIds, - form.getFieldValue("creators"), - form.getFieldValue("tags"), + [], + [], ); }} style={{ padding: 0, marginLeft: 16 }} @@ -315,8 +385,8 @@ const ChatSelector = forwardRef( setSelectedIds(defaultKnowledgeId); onChange?.( defaultKnowledgeId, - form.getFieldValue("creators"), - form.getFieldValue("tags"), + [], + [], ); }} > @@ -332,7 +402,7 @@ const ChatSelector = forwardRef(
handleItemClick(item.dataset_id)} + onClick={() => handleItemClick(item)} > {item.display_name} @@ -350,97 +420,27 @@ const ChatSelector = forwardRef(
{t("chat.noData")}
) : null}
- {renderConfigBottom()} - - ); - } - - function renderConfigBottom() { - return ( -
-
- - - {t("chat.docSettings")} - {showConfig && {t("chat.enabled")}} - - {showConfig ? ( - setShowConfig(false)} /> - ) : ( - setShowConfig(true)} /> - )} -
- {showConfig && ( - <> - - - onChange?.(selectedIds, form.getFieldValue("creators"), val) - } - allowClear - placeholder={t("chat.selectTag")} - maxTagCount="responsive" - popupMatchSelectWidth - showSearch - optionLabelProp="value" - filterOption={false} - options={tags.map((tag) => ({ value: tag, label: tag }))} - /> - - - - )}
); } return ( -
-
- setOpen(bool)} +
+ setOpen(bool)} + > +
0 ? "selected" : ""}`} > -
0 ? "selected" : ""}`} - > - - {t("chat.knowledgeBase")} -
- -
- + + {t("chat.knowledgeBase")} +
+
+
); }, ); diff --git a/frontend/src/modules/chat/components/ConversationSettingModal/index.tsx b/frontend/src/modules/chat/components/ConversationSettingModal/index.tsx index 4a96bce..ae72345 100644 --- a/frontend/src/modules/chat/components/ConversationSettingModal/index.tsx +++ b/frontend/src/modules/chat/components/ConversationSettingModal/index.tsx @@ -1,4 +1,5 @@ import { CommonModal } from "@/components/ui"; +import { getLocalizedErrorMessage } from "@/components/request"; import { Switch, Space, message } from "antd"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -51,9 +52,12 @@ function ConversationSettingModal(props: ConversationSettingModalProps) { onStatusChange?.(); cancelFn(); } catch (error: any) { - message.error( - error?.response?.data?.message || t("chat.settingSaveFailed"), - ); + if (!error?.response && !error?.request) { + message.error( + getLocalizedErrorMessage(error, t("chat.settingSaveFailed")) || + t("chat.settingSaveFailed"), + ); + } } } diff --git a/frontend/src/modules/chat/components/PromptModal/index.tsx b/frontend/src/modules/chat/components/PromptModal/index.tsx index 6f67efa..66e89a4 100644 --- a/frontend/src/modules/chat/components/PromptModal/index.tsx +++ b/frontend/src/modules/chat/components/PromptModal/index.tsx @@ -65,7 +65,7 @@ const PromptModal = forwardRef( function fetchPromptList() { PromptServiceApi() - .promptServiceListPrompts({ pageSize: 2 }) + .promptServiceListPrompts({ pageSize: 9999 }) .then((res) => { setPromptList(res.data.prompts ? [...res.data?.prompts] : []); }); @@ -327,6 +327,11 @@ const PromptModal = forwardRef( rows={5} showCount maxLength={800} + style={{ + width: "100%", + height: "132px", + resize: "none", + }} /> diff --git a/frontend/src/modules/chat/components/RecordList/index.scss b/frontend/src/modules/chat/components/RecordList/index.scss index 363f866..2772e8f 100644 --- a/frontend/src/modules/chat/components/RecordList/index.scss +++ b/frontend/src/modules/chat/components/RecordList/index.scss @@ -31,7 +31,43 @@ font-style: normal; font-weight: 600; line-height: 22px; - margin-bottom: 8px; + margin-bottom: 0; + } + + .record-header { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 4px; + } + + .record-header-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-right: 44px; + } + + .record-toolbar { + display: block; + } + + .record-toolbar-search { + width: 100%; + } + + .record-toolbar-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + flex-shrink: 0; + white-space: nowrap; + + .ant-btn { + padding-inline: 6px; + } } .record { @@ -111,3 +147,15 @@ padding-left: 16px; } } + +@media (max-width: 1280px) { + .record-container { + .record-header-top { + align-items: flex-start; + } + + .record-toolbar-actions { + margin-right: -6px; + } + } +} diff --git a/frontend/src/modules/chat/components/RecordList/index.tsx b/frontend/src/modules/chat/components/RecordList/index.tsx index eb27f8a..0d35c15 100644 --- a/frontend/src/modules/chat/components/RecordList/index.tsx +++ b/frontend/src/modules/chat/components/RecordList/index.tsx @@ -10,14 +10,16 @@ import { Spin, Tooltip, } from "antd"; +import { Conversation } from "@/api/generated/chatbot-client"; import { - Conversation, - ExportConversationsRequestFileTypesEnum, -} from "@/api/generated/chatbot-client"; + Configuration as CoreConfiguration, + ConversationsApiFactory, +} from "@/api/generated/core-client"; import { useEffect, useRef, forwardRef, useImperativeHandle } from "react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import InfiniteScroll from "react-infinite-scroll-component"; +import { axiosInstance, BASE_URL } from "@/components/request"; import { useChatThinkStore } from "@/modules/chat/store/chatThink"; import { useChatNewMessageStore } from "@/modules/chat/store/chatNewMessage"; @@ -25,7 +27,30 @@ import dayjs from "dayjs"; import { ChatServiceApi } from "@/modules/chat/utils/request"; import "./index.scss"; -import { downloadUrl } from "@/modules/chat/utils/download"; +import { downloadStream } from "@/modules/chat/utils/download"; + +const EXPORT_FILE_TYPE_XLSX = "EXPORT_FILE_TYPE_XLSX"; +const conversationsClient = ConversationsApiFactory( + new CoreConfiguration({ basePath: BASE_URL }), + BASE_URL, + axiosInstance, +); + +function getExportFileId(uri?: string) { + if (!uri) return ""; + const matched = uri.match(/\/conversation:export\/files\/([^/?#]+)/); + return matched?.[1] ?? ""; +} + +function getDownloadFileName(contentDisposition?: string) { + if (!contentDisposition) return "conversations-export"; + const utf8Matched = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Matched?.[1]) { + return decodeURIComponent(utf8Matched[1]); + } + const matched = contentDisposition.match(/filename="?([^"]+)"?/i); + return matched?.[1] ?? "conversations-export"; +} interface IRecordList { currentSessionId: string; @@ -117,19 +142,30 @@ const RecordList = forwardRef( } function exportHistoryFn() { - ChatServiceApi() - .conversationServiceExportConversations({ + conversationsClient + .apiCoreConversationExportPost({ exportConversationsRequest: { conversation_ids: checkedList, - file_types: [ - ExportConversationsRequestFileTypesEnum.ExportFileTypeXlsx, - ], + file_types: [EXPORT_FILE_TYPE_XLSX], }, }) - .then((res) => { + .then(async (res) => { const { uris = [] } = res.data; if (uris?.length) { - downloadUrl(uris[0]); + const fileId = getExportFileId(uris[0]); + if (!fileId) { + message.error("导出文件地址异常"); + return; + } + const downloadRes = + await conversationsClient.apiCoreConversationExportFilesFileIdGet( + { fileId }, + { responseType: "blob" }, + ); + downloadStream( + downloadRes.data as Blob, + getDownloadFileName(downloadRes.headers["content-disposition"]), + ); } else { message.warning(t("chat.noConversationToExport")); } @@ -199,52 +235,56 @@ const RecordList = forwardRef( return (
-
{t("chat.chatHistory")}
-
- { - getHistory({ searchText: value, isFirst: true }); - setKeyword(value); - }} - /> -
- {showBatchExport ? ( - <> +
+
+
{t("chat.chatHistory")}
+
+ {showBatchExport ? ( + <> + + + + ) : ( - - - ) : ( - - )} + )} +
+
+
+ { + getHistory({ searchText: value, isFirst: true }); + setKeyword(value); + }} + />
{showBatchExport && ( diff --git a/frontend/src/modules/chat/components/newChatContainer/index.tsx b/frontend/src/modules/chat/components/newChatContainer/index.tsx index beb74ab..e533c9c 100644 --- a/frontend/src/modules/chat/components/newChatContainer/index.tsx +++ b/frontend/src/modules/chat/components/newChatContainer/index.tsx @@ -41,6 +41,8 @@ import type { PreferenceType } from "../MultiAnswerDisplay"; import { ChatServiceApi } from "@/modules/chat/utils/request"; import { useChatMessageStore } from "@/modules/chat/store/chatMessage"; import { CHAT_RESUME_CONVERSATION_KEY } from "@/modules/chat/constants/chat"; +import { useTranslation } from "react-i18next"; +import { getRegenerationInputs } from "@/modules/chat/utils/message"; const ThinkIcon = new URL("../../assets/images/think.png", import.meta.url) .href; @@ -108,6 +110,7 @@ export interface ChatMessage { const ChatContainerComponent = forwardRef( (props, ref) => { + const { t } = useTranslation(); const { canChat = true, initialCard, @@ -975,6 +978,14 @@ const ChatContainerComponent = forwardRef( if (loading) { return; } + const userMessage = messageListRef.current.findLast( + (item: any) => item.role === RoleTypes.USER, + ); + const regenerationInputs = getRegenerationInputs(userMessage); + if (regenerationInputs.length < 1) { + message.error(t("chat.regenerateInputMissing")); + return; + } const currentId = currentConversationIdRef.current; if (currentId) { @@ -996,7 +1007,7 @@ const ChatContainerComponent = forwardRef( selected_answer_index: undefined, answer_preference: undefined, }; - const newList = [...messageList]; + const newList = [...messageListRef.current]; newList[newList.length - 1] = assistantMessage; messageListRef.current = newList; setMessageList(newList); @@ -1006,12 +1017,9 @@ const ChatContainerComponent = forwardRef( streamManager.saveMessageList(currentId, newList); } - const userMessage = messageList.findLast( - (item: any) => item.role === RoleTypes.USER, - ); isMouseScrollingRef.current = true; openSSE( - userMessage?.inputs, + regenerationInputs, ChatConversationsRequestActionEnum.ChatActionRegeneration, ); } @@ -1083,15 +1091,36 @@ const ChatContainerComponent = forwardRef( ); } - const handleScroll = () => { + const getScrollMetrics = useCallback(() => { const el = chatContentRef.current; if (!el) { - return; + return null; } + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; - const hasScrollbar = el.scrollHeight > el.clientHeight + 2; - setShowScrollButton(hasScrollbar && distance > 10); - if (distance <= 10) { + return { + distance, + hasScrollbar: el.scrollHeight > el.clientHeight + 2, + }; + }, []); + + const updateScrollButtonVisibility = useCallback(() => { + const metrics = getScrollMetrics(); + if (!metrics) { + return; + } + + setShowScrollButton(metrics.hasScrollbar && metrics.distance > 10); + }, [getScrollMetrics]); + + const handleScroll = () => { + const metrics = getScrollMetrics(); + if (!metrics) { + return; + } + + setShowScrollButton(metrics.hasScrollbar && metrics.distance > 10); + if (metrics.distance <= 10) { isMouseScrollingRef.current = true; } else { isMouseScrollingRef.current = false; @@ -1105,19 +1134,16 @@ const ChatContainerComponent = forwardRef( } isMouseScrollingRef.current = true; el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - const hasScrollbar = el.scrollHeight > el.clientHeight + 2; - setShowScrollButton(hasScrollbar && false); + setShowScrollButton(false); }; useEffect(() => { - const el = chatContentRef.current; - if (!el) { - return; - } - const distance = el.scrollHeight - el.scrollTop - el.clientHeight; - const hasScrollbar = el.scrollHeight > el.clientHeight + 2; - setShowScrollButton(hasScrollbar && distance > 10); - }, [messageList]); + const rafId = requestAnimationFrame(() => { + updateScrollButtonVisibility(); + }); + + return () => cancelAnimationFrame(rafId); + }, [messageList, thinkingCollapseMap, inputHeight, updateScrollButtonVisibility]); useEffect(() => { const updateInputHeight = () => { diff --git a/frontend/src/modules/chat/layout/index.tsx b/frontend/src/modules/chat/layout/index.tsx index d1e09f5..ce0573c 100644 --- a/frontend/src/modules/chat/layout/index.tsx +++ b/frontend/src/modules/chat/layout/index.tsx @@ -1,6 +1,7 @@ import { ReactNode } from "react"; import { ConfigProvider } from "antd"; -import zhCN from "antd/locale/zh_CN"; +import { useTranslation } from "react-i18next"; +import { getAntdLocale } from "@/i18n/antdLocale"; const Layout = ({ token = {}, @@ -9,8 +10,13 @@ const Layout = ({ token?: object; children?: ReactNode; }) => { + const { i18n } = useTranslation(); + return ( - + {children} ); diff --git a/frontend/src/modules/chat/pages/chat/index.tsx b/frontend/src/modules/chat/pages/chat/index.tsx index cc948ba..b778617 100644 --- a/frontend/src/modules/chat/pages/chat/index.tsx +++ b/frontend/src/modules/chat/pages/chat/index.tsx @@ -27,6 +27,7 @@ import { Method, SSE } from "@/modules/chat/utils/sse"; import { CHAT_STREAM_URL, ChatServiceApi } from "@/modules/chat/utils/request"; import { useEffect } from "react"; import { useConversationSettings } from "@/modules/chat/store/conversationSettings"; +import { normalizeMessageInputs } from "@/modules/chat/utils/message"; const ChatPage: FC = () => { const { t } = useTranslation(); @@ -124,11 +125,20 @@ const ChatPage: FC = () => { const list: ChatMessage[] = []; if (history && history.length > 0) { history.forEach((record: ChatHistory) => { + const normalizedInputs = normalizeMessageInputs( + record.input, + record.query, + ); + const textInput = normalizedInputs.find((input) => { + const inputType = input.input_type || "text"; + return inputType === "text" && !!input.text; + }); + // Push user. list.push({ role: RoleTypes.USER, - delta: record.query, - images: record.input + delta: record.query || textInput?.text || "", + images: normalizedInputs ?.filter((input) => { return input.input_type === "image"; }) @@ -138,7 +148,7 @@ const ChatPage: FC = () => { uid: image.file_id, }; }), - files: record.input + files: normalizedInputs ?.filter((input) => { return input.input_type === "file"; }) @@ -147,10 +157,10 @@ const ChatPage: FC = () => { name: file?.uri?.split("/").pop(), uid: file.file_id, }; - }), + }), finish_reason: ChatConversationsResponseFinishReasonEnum.FinishReasonStop, - inputs: record.input, + inputs: normalizedInputs, create_time: record.create_time || "xxx-xxx-xxx", }); diff --git a/frontend/src/modules/chat/pages/chatLayout/index.scss b/frontend/src/modules/chat/pages/chatLayout/index.scss index d2d74b4..722ce2f 100644 --- a/frontend/src/modules/chat/pages/chatLayout/index.scss +++ b/frontend/src/modules/chat/pages/chatLayout/index.scss @@ -75,7 +75,8 @@ .right-box { box-sizing: border-box; - width: 384px; + width: clamp(280px, 22vw, 384px); + min-width: 280px; height: 100%; border-left: 1px solid #dfe6ef; padding: 24px 16px; @@ -94,9 +95,17 @@ } } - @media (max-width: 1380px) { + @media (max-width: 1280px) { + .right-box { + width: 256px; + min-width: 256px; + } + } + + @media (max-width: 960px) { .right-box { width: 224px; + min-width: 224px; } } } diff --git a/frontend/src/modules/chat/pages/chatLayout/index.tsx b/frontend/src/modules/chat/pages/chatLayout/index.tsx index 1935d2d..35ae5d6 100644 --- a/frontend/src/modules/chat/pages/chatLayout/index.tsx +++ b/frontend/src/modules/chat/pages/chatLayout/index.tsx @@ -37,6 +37,7 @@ import { } from "@/modules/chat/store/modelSelection"; import { allowedUploadTypes } from "@/modules/chat/components/ImageUpload"; import { CHAT_RESUME_CONVERSATION_KEY } from "@/modules/chat/constants/chat"; +import { normalizeMessageInputs } from "@/modules/chat/utils/message"; interface IChatLayoutProps { setIsChatContent: (isChatContent: boolean) => void; initchatConfig: ChatConfig; @@ -141,16 +142,25 @@ const ChatLayout: FC = (props) => { if (history?.length) { const lastHistory = history[history.length - 1]; history.forEach((record: ChatHistory) => { + const normalizedInputs = normalizeMessageInputs( + record.input, + record.query, + ); + const textInput = normalizedInputs.find((input) => { + const inputType = input.input_type || "text"; + return inputType === "text" && !!input.text; + }); + list.push({ role: RoleTypes.USER, - delta: record.query, - images: record.input + delta: record.query || textInput?.text || "", + images: normalizedInputs ?.filter((i: any) => i.input_type === "image") .map((img: any) => ({ base64: img?.input_base64, uid: img.file_id, })), - files: record.input + files: normalizedInputs ?.filter((i: any) => i.input_type === "file") .map((f: any) => ({ name: f?.uri?.split("/").pop(), @@ -158,7 +168,7 @@ const ChatLayout: FC = (props) => { })), finish_reason: ChatConversationsResponseFinishReasonEnum.FinishReasonStop, - inputs: record.input, + inputs: normalizedInputs, create_time: record.create_time || "", }); const isLastRecord = record === lastHistory; @@ -359,11 +369,20 @@ const ChatLayout: FC = (props) => { const list: ChatMessage[] = []; if (history && history.length > 0) { history.forEach((record: ChatHistory) => { + const normalizedInputs = normalizeMessageInputs( + record.input, + record.query, + ); + const textInput = normalizedInputs.find((input) => { + const inputType = input.input_type || "text"; + return inputType === "text" && !!input.text; + }); + // Push user. list.push({ role: RoleTypes.USER, - delta: record.query, - images: record.input + delta: record.query || textInput?.text || "", + images: normalizedInputs ?.filter((input) => { return input.input_type === "image"; }) @@ -373,7 +392,7 @@ const ChatLayout: FC = (props) => { uid: image.file_id, }; }), - files: record.input + files: normalizedInputs ?.filter((input) => { return input.input_type === "file"; }) @@ -382,10 +401,10 @@ const ChatLayout: FC = (props) => { name: file?.uri?.split("/").pop(), uid: file.file_id, }; - }), + }), finish_reason: ChatConversationsResponseFinishReasonEnum.FinishReasonStop, - inputs: record.input, + inputs: normalizedInputs, create_time: record.create_time || "xxx-xxx-xxx", }); diff --git a/frontend/src/modules/chat/utils/message.ts b/frontend/src/modules/chat/utils/message.ts new file mode 100644 index 0000000..376cb86 --- /dev/null +++ b/frontend/src/modules/chat/utils/message.ts @@ -0,0 +1,42 @@ +import type { Query } from "@/api/generated/chatbot-client"; + +interface ChatUserMessageLike { + delta?: string; + inputs?: Query[] | null; +} + +export function normalizeMessageInputs( + inputs?: Query[] | null, + fallbackText?: string, +): Query[] { + const normalizedInputs = Array.isArray(inputs) + ? inputs + .filter((item): item is Query => !!item) + .map((item) => ({ ...item })) + : []; + + const trimmedFallbackText = fallbackText?.trim(); + const hasTextInput = normalizedInputs.some((item) => { + const inputType = item.input_type || "text"; + return inputType === "text" && !!item.text?.trim(); + }); + + if (!hasTextInput && trimmedFallbackText) { + normalizedInputs.unshift({ + input_type: "text", + text: fallbackText, + }); + } + + return normalizedInputs; +} + +export function getRegenerationInputs( + userMessage?: ChatUserMessageLike, +): Query[] { + if (!userMessage) { + return []; + } + + return normalizeMessageInputs(userMessage.inputs, userMessage.delta); +} diff --git a/frontend/src/modules/chat/utils/request.ts b/frontend/src/modules/chat/utils/request.ts index 131fc62..1973917 100644 --- a/frontend/src/modules/chat/utils/request.ts +++ b/frontend/src/modules/chat/utils/request.ts @@ -6,7 +6,6 @@ import { type ConversationDetail, type ConversationServiceApiConversationServiceBatchChatRequest, type ConversationServiceApiConversationServiceDeleteConversationRequest, - type ConversationServiceApiConversationServiceExportConversationsRequest, type ConversationServiceApiConversationServiceFeedBackChatHistoryRequest, type ConversationServiceApiConversationServiceGetBatchChatJobRequest, type ConversationServiceApiConversationServiceGetChatStatusRequest, @@ -16,7 +15,6 @@ import { type ConversationServiceApiConversationServiceSetChatHistoryRequest, type ConversationServiceApiConversationServiceSetMultiAnswersSwitchStatusRequest, type ConversationServiceApiConversationServiceStopChatGenerationRequest, - type ExportConversationsResponse, type FileServiceApiFileServicePresignAttachmentRequest, type GetChatStatusResponse, type GetMultiAnswersSwitchStatusResponse, @@ -160,16 +158,6 @@ export function ChatServiceApi() { options, ); }, - conversationServiceExportConversations( - requestParameters: ConversationServiceApiConversationServiceExportConversationsRequest, - options?: RawAxiosRequestConfig, - ) { - return axiosInstance.post( - `${BASE_URL}/api/v1/conversation:export`, - requestParameters.exportConversationsRequest, - withJsonOptions(options), - ); - }, conversationServiceBatchChat( requestParameters: ConversationServiceApiConversationServiceBatchChatRequest, options?: RawAxiosRequestConfig, diff --git a/frontend/src/modules/knowledge/components/TagSelect/index.tsx b/frontend/src/modules/knowledge/components/TagSelect/index.tsx index d313b69..2b0c127 100644 --- a/frontend/src/modules/knowledge/components/TagSelect/index.tsx +++ b/frontend/src/modules/knowledge/components/TagSelect/index.tsx @@ -1,5 +1,5 @@ import { ALL_TAGS } from "@/modules/knowledge/constants/common"; -import { Select } from "antd"; +import { message, Select } from "antd"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -11,6 +11,7 @@ interface TagSelectProps { const TagSelect = ({ tags, value = [], onChange }: TagSelectProps) => { const { t } = useTranslation(); + const MAX_TAG_COUNT = 10; const MAX_TAG_LENGTH = 100; const [searchValue, setSearchValue] = useState(""); @@ -23,14 +24,37 @@ const TagSelect = ({ tags, value = [], onChange }: TagSelectProps) => { } } + function handleChange(nextValue: string[]) { + const normalizedTags = Array.from( + new Set((nextValue || []).map((tag) => tag.trim()).filter(Boolean)), + ); + const validLengthTags = normalizedTags.filter( + (tag) => tag.length <= MAX_TAG_LENGTH, + ); + + if (validLengthTags.length < normalizedTags.length) { + message.warning( + t("knowledge.singleTagMaxLength", { count: MAX_TAG_LENGTH }), + ); + } + + if (validLengthTags.length > MAX_TAG_COUNT) { + message.warning(t("knowledge.maxTenTags")); + setSearchValue(""); + onChange?.(validLengthTags.slice(0, MAX_TAG_COUNT)); + return; + } + + onChange?.(validLengthTags); + } + return ( { key: "action", width: 102, render: (record: MemberRecord) => { + const canDeleteMemberPermission = + currentDetail?.acl?.includes(DatasetAclEnum.DatasetWrite) && + !isCreator(record); + return ( - currentDetail?.acl?.includes(DatasetAclEnum.DatasetWrite) && ( + canDeleteMemberPermission && (
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 12954ee..af6bdc0 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,6 +1,6 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { ConfigProvider } from "antd"; -import zhCN from "antd/locale/zh_CN"; +import { useTranslation } from "react-i18next"; import MainLayout from "@/layouts/MainLayout"; import SigninLogin from "@/modules/signin/pages/login"; import SigninRegister from "@/modules/signin/pages/register"; @@ -17,10 +17,13 @@ import AdminLayout from "@/modules/admin/AdminLayout"; import UserManagement from "@/modules/admin/pages/user"; import GroupManagement from "@/modules/admin/pages/group"; import GroupDetail from "@/modules/admin/pages/group/detail.tsx"; +import { getAntdLocale } from "@/i18n/antdLocale"; export default function AppRouter() { + const { i18n } = useTranslation(); + return ( - + }> } />