package httpapi import ( "archive/zip" "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "encoding/xml" "errors" "fmt" "net/http" "net/url" "os" "strconv" "strings" "time" "git.118im.com/im_team/im_workbench/internal/ai" "git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/im_workbench/internal/tableparse" ) const ( reasonAINotConfigured = "AI_PROVIDER_NOT_CONFIGURED" reasonAIProviderFailed = "AI_PROVIDER_FAILED" reasonAITimeout = "AI_TIMEOUT" reasonAITaskFailed = "AI_TASK_FAILED" reasonAIFeatureClosed = "AI_FEATURE_DISABLED" ) func (a *API) aiCapabilities(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } caps, err := st.AICapabilities(r.Context()) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, map[string]any{ "enabled": anyCapabilityEnabled(caps), "features": aiCapabilityPayloads(caps), }) } func (a *API) aiTextPolish(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodPost { methodNotAllowed(w, r) return } if _, ok := requireIdempotencyKey(w, r); !ok { return } var body aiPolishRequest if !decodeBody(w, r, &body) { return } feature := aiPolishFeature(body.Scene) cap, ok := a.requireAICapability(w, r, st, feature) if !ok { return } source := strings.TrimSpace(body.SourceText) maxInput := aiMaxInputChars(cap, 1000) if source == "" || len([]rune(source)) > maxInput { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "source_text", "max_length": maxInput}) return } maxOutput := body.MaxOutputChars if maxOutput <= 0 { maxOutput = 300 } result, err := a.ai.Polish(r.Context(), source, strings.TrimSpace(body.Scene), strings.TrimSpace(body.Tone), maxOutput) if err != nil { a.writeAIError(w, r, err) _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: feature, Provider: "deepseek", Status: "failed", ErrorReason: aiErrorReason(err), InputHash: hashText(source), }) return } _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: feature, Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(source), OutputHash: hashText(result.Text), }) writeSuccess(w, r, map[string]any{ "source_text": source, "polished_text": result.Text, "usage": map[string]any{"remaining_today": 0}, }) } func (a *API) aiDocumentGenerate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodPost { methodNotAllowed(w, r) return } if _, ok := requireIdempotencyKey(w, r); !ok { return } var body aiDocumentGenerateRequest if !decodeBody(w, r, &body) { return } cap, ok := a.requireAICapability(w, r, st, "smart_document") if !ok { return } source := strings.TrimSpace(body.SourceText) maxInput := aiMaxInputChars(cap, 1000) if source == "" || len([]rune(source)) > maxInput { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "source_text", "max_length": maxInput}) return } taskID := "aitask_" + randomToken(12) started := time.Now().UTC().Round(time.Millisecond) if err := st.CreateAITask(r.Context(), store.AITask{ TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "smart_document", Status: "processing", Progress: 10, SourceType: "text", InputHash: hashText(source), PromptVersion: "smart_document_v1", Model: "", StartedAt: &started, }, nil); err != nil { writeStoreError(w, r, err) return } result, err := a.ai.GenerateDocument(r.Context(), source, strings.TrimSpace(body.TitleHint), strings.TrimSpace(body.DocumentType), strings.TrimSpace(body.Language)) if err != nil { _ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error()) _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_document", TaskID: taskID, Provider: "deepseek", Status: "failed", ErrorReason: aiErrorReason(err), InputHash: hashText(source), }) a.writeAIError(w, r, err) return } taskResult := map[string]any{ "title": result.Title, "summary": result.Summary, "content_markdown": result.ContentMarkdown, "content_plain": result.ContentPlain, "sections": result.Sections, "source_excerpt": excerpt(source, 200), "can_save": true, } if err := st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, "", taskResult, "", ""); err != nil { writeStoreError(w, r, err) return } _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_document", TaskID: taskID, Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(source), OutputHash: hashText(result.ContentMarkdown), }) writeSuccess(w, r, map[string]any{ "task_id": taskID, "status": "succeeded", "polling_interval_ms": 1500, "expires_at": time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339Nano), }) } func (a *API) aiTaskResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { taskID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/tasks/") if !ok { writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) return } if tail == "cancel" && r.Method == http.MethodPost { if _, ok := requireIdempotencyKey(w, r); !ok { return } if err := st.CancelAITask(r.Context(), p.UserID, taskID); err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, map[string]any{"task_id": taskID, "status": "canceled", "canceled_at": time.Now().UTC().Format(time.RFC3339Nano)}) return } if tail != "" { writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) return } if r.Method != http.MethodGet { methodNotAllowed(w, r) return } task, err := st.AITask(r.Context(), p.UserID, taskID) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, aiTaskPayload(task)) } func (a *API) aiDocuments(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { switch r.Method { case http.MethodGet: items, next, err := st.AIDocuments(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, pagePayload(aiRecordPayloads(items), next)) case http.MethodPost: a.aiDocumentSave(w, r, p, st) default: methodNotAllowed(w, r) } } func (a *API) aiDocumentSave(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if _, ok := requireIdempotencyKey(w, r); !ok { return } var body aiDocumentSaveRequest if !decodeBody(w, r, &body) { return } title := strings.TrimSpace(body.Title) content := strings.TrimSpace(body.ContentMarkdown) if title == "" || content == "" || strings.TrimSpace(body.TaskID) == "" { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil) return } task, err := st.AITask(r.Context(), p.UserID, strings.TrimSpace(body.TaskID)) if err != nil { writeStoreError(w, r, err) return } if task.Status != "succeeded" { writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", map[string]any{"status": task.Status}) return } recordID := "airec_doc_" + randomToken(10) sectionsJSON := "[]" if len(body.Sections) > 0 { if data, err := json.Marshal(body.Sections); err == nil { sectionsJSON = string(data) } } else if task.ResultJSON.Valid { var result map[string]any if json.Unmarshal([]byte(task.ResultJSON.String), &result) == nil { if sections, ok := result["sections"]; ok { if data, err := json.Marshal(sections); err == nil { sectionsJSON = string(data) } } } } if err := st.SaveAIDocument(r.Context(), p.TenantCode, p.UserID, store.AIRecord{ RecordID: recordID, RecordType: "smart_document", Title: title, Summary: excerpt(firstNonEmpty(body.ContentPlain, content), 120), Status: "succeeded", TaskID: task.TaskID, }, store.AIDocument{ RecordID: recordID, DocumentType: firstNonEmpty(body.DocumentType, "general"), Title: title, ContentMarkdown: content, ContentPlain: strings.TrimSpace(body.ContentPlain), SectionsJSON: sectionsJSON, SourceExcerpt: "", PromptVersion: task.PromptVersion, Model: task.Model, }); err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, map[string]any{ "record_id": recordID, "title": title, "created_at": time.Now().UTC().Format(time.RFC3339Nano), "updated_at": time.Now().UTC().Format(time.RFC3339Nano), }) } func (a *API) aiDocumentResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/documents/") if !ok || tail != "" { writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) return } if r.Method != http.MethodGet { methodNotAllowed(w, r) return } doc, err := st.AIDocument(r.Context(), p.UserID, recordID) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, aiDocumentPayload(doc)) } func (a *API) aiRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } items, next, err := st.AIRecords(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("type")), strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, pagePayload(aiGenericRecordPayloads(items), next)) } func (a *API) aiRecordResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/records/") if !ok || tail != "" { writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) return } if r.Method != http.MethodDelete { methodNotAllowed(w, r) return } if err := st.DeleteAIRecord(r.Context(), p.UserID, recordID); err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, map[string]any{"record_id": recordID, "deleted": true}) } func (a *API) aiTableAnalyses(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { switch r.Method { case http.MethodGet: items, next, err := st.AITableAnalyses(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, pagePayload(aiTableRecordPayloads(items), next)) case http.MethodPost: a.aiTableAnalysisCreate(w, r, p, st) default: methodNotAllowed(w, r) } } func (a *API) aiTableAnalysisCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if _, ok := requireIdempotencyKey(w, r); !ok { return } var body aiTableAnalysisRequest if !decodeBody(w, r, &body) { return } cap, ok := a.requireAICapability(w, r, st, "smart_table") if !ok { return } fileID := strings.TrimSpace(body.FileID) topic := strings.TrimSpace(body.Topic) fileType := strings.ToLower(strings.TrimSpace(body.FileType)) if fileID == "" || topic == "" || !allowedTableFileType(fileType) { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil) return } maxInput := aiMaxInputChars(cap, 100) if len([]rune(topic)) > maxInput { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "topic", "max_length": maxInput}) return } fileMeta := st.AIFileMetadata(r.Context(), fileID) fileSize := body.FileSize if fileSize <= 0 && fileMeta.FileSize > 0 { fileSize = fileMeta.FileSize } if cap.MaxFileSizeMB > 0 && fileSize > int64(cap.MaxFileSizeMB)*1024*1024 { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "file_size", "max_file_size_mb": cap.MaxFileSizeMB}) return } taskID := "aitask_tbl_" + randomToken(12) recordID := "airec_tbl_" + randomToken(10) started := time.Now().UTC().Round(time.Millisecond) if err := st.CreateAITask(r.Context(), store.AITask{ TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "smart_table", Status: "processing", Progress: 20, SourceType: "file", SourceRefID: fileID, InputHash: hashText(fileID + ":" + topic), PromptVersion: "smart_table_v1", StartedAt: &started, }, nil); err != nil { writeStoreError(w, r, err) return } profile := tableProfileForAI(resolveTableFilePath(fileID, fileMeta.Location), fileType) result, err := a.ai.AnalyzeTable(r.Context(), ai.TableAnalysisInput{ Topic: topic, FileID: fileID, FileName: strings.TrimSpace(body.FileName), FileType: fileType, FileSize: fileSize, Profile: profile, }) if err != nil { _ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error()) a.writeAIError(w, r, err) return } metricsJSON := mustJSON(result.Metrics, "[]") previewJSON := mustJSON(result.Preview, "{}") alertsJSON := mustJSON(result.Alerts, "[]") if err := st.SaveAITableAnalysis(r.Context(), p.TenantCode, p.UserID, store.AIRecord{ RecordID: recordID, RecordType: "smart_table", Title: result.Title, Summary: excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160), Status: "succeeded", TaskID: taskID, }, store.AITableAnalysis{ RecordID: recordID, FileID: fileID, FileName: strings.TrimSpace(body.FileName), FileType: fileType, FileSize: fileSize, Topic: topic, MetricsJSON: metricsJSON, PreviewJSON: previewJSON, AlertsJSON: alertsJSON, Interpretation: result.Interpretation, PromptVersion: "smart_table_v1", Model: result.Usage.Model, }); err != nil { writeStoreError(w, r, err) return } taskResult := map[string]any{"record_id": recordID, "title": result.Title, "metrics": result.Metrics, "alerts": result.Alerts, "interpretation": result.Interpretation} if err := st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, recordID, taskResult, "", ""); err != nil { writeStoreError(w, r, err) return } _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_table", TaskID: taskID, Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(fileID + ":" + topic), OutputHash: hashText(result.Interpretation), }) writeSuccess(w, r, map[string]any{"task_id": taskID, "record_id": recordID, "status": "succeeded", "polling_interval_ms": 1500}) } func (a *API) aiTableAnalysisResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/table-analyses/") if !ok { writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) return } if tail == "export" && r.Method == http.MethodPost { if _, ok := requireIdempotencyKey(w, r); !ok { return } var body aiTableExportRequest if !decodeBody(w, r, &body) { return } format := strings.ToLower(strings.TrimSpace(body.Format)) if format == "" { format = "docx" } if format != "docx" { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "format", "supported": []string{"docx"}}) return } item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) if err != nil { writeStoreError(w, r, err) return } file, err := a.uploadAITableAnalysisExport(r.Context(), r, st, p, item) if err != nil { writeError(w, r, http.StatusServiceUnavailable, 30053, reasonAIProviderFailed, "ai export storage failed", nil) return } writeSuccess(w, r, map[string]any{ "file_id": file.FileID, "file_name": file.FileName, "download_url": file.DownloadURL, "expires_at": time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339Nano), }) return } if tail == "export/download" && r.Method == http.MethodGet { format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) if format == "" { format = "docx" } if format != "docx" { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "format", "supported": []string{"docx"}}) return } item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) if err != nil { writeStoreError(w, r, err) return } data, err := aiTableAnalysisDocx(item) if err != nil { writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) return } fileName := aiTableExportFileName(item) w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") w.Header().Set("Content-Disposition", contentDispositionAttachment(fileName)) w.Header().Set("Content-Length", strconv.Itoa(len(data))) w.WriteHeader(http.StatusOK) _, _ = w.Write(data) return } if tail != "" || r.Method != http.MethodGet { methodNotAllowed(w, r) return } item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, aiTableAnalysisPayload(item)) } func (a *API) aiMeetingSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { switch r.Method { case http.MethodGet: if _, err := st.Meeting(r.Context(), p.UserID, meetingID); err != nil { writeStoreError(w, r, err) return } summary, err := st.AIMeetingSummary(r.Context(), p.UserID, meetingID) if errors.Is(err, store.ErrNotFound) { writeSuccess(w, r, map[string]any{"meeting_id": meetingID, "status": "empty"}) return } if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, aiMeetingSummaryPayload(summary)) case http.MethodPost: a.aiMeetingSummaryCreate(w, r, p, st, meetingID) case http.MethodPut: a.aiMeetingSummaryUpdate(w, r, p, st, meetingID) default: methodNotAllowed(w, r) } } func (a *API) aiMeetingSummaryCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { if _, ok := requireIdempotencyKey(w, r); !ok { return } var body aiMeetingSummaryRequest if !decodeBody(w, r, &body) { return } if _, ok := a.requireAICapability(w, r, st, "meeting_summary"); !ok { return } meeting, err := st.Meeting(r.Context(), p.UserID, meetingID) if err != nil { writeStoreError(w, r, err) return } files, err := st.MeetingNoteFiles(r.Context(), p.UserID, meetingID) if err != nil { writeStoreError(w, r, err) return } fileNames, fileIDs := meetingSummaryFiles(files, body.FileIDs) taskID := "aitask_mtg_" + randomToken(12) summaryID := "aisum_" + randomToken(10) started := time.Now().UTC().Round(time.Millisecond) if err := st.CreateAITask(r.Context(), store.AITask{ TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "meeting_summary", Status: "processing", Progress: 20, SourceType: "meeting", SourceRefID: meetingID, InputHash: hashText(meetingID + strings.Join(fileIDs, ",")), PromptVersion: "meeting_summary_v1", StartedAt: &started, }, nil); err != nil { writeStoreError(w, r, err) return } result, err := a.ai.SummarizeMeeting(r.Context(), ai.MeetingSummaryInput{ MeetingTitle: meeting.Title, StartAt: meeting.StartAt.UTC().Format(time.RFC3339Nano), EndAt: meeting.EndAt.UTC().Format(time.RFC3339Nano), Participants: meetingSummaryParticipants(*meeting), Files: fileNames, }) if err != nil { _ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error()) a.writeAIError(w, r, err) return } if err := st.UpsertAIMeetingSummary(r.Context(), p.TenantCode, p.UserID, store.AIMeetingSummary{ SummaryID: summaryID, MeetingID: meetingID, TaskID: taskID, ConclusionsJSON: mustJSON(result.Conclusions, "[]"), DecisionsJSON: mustJSON(result.Decisions, "[]"), ActionItemsJSON: mustJSON(result.ActionItems, "[]"), SourceFileIDsJSON: mustJSON(fileIDs, "[]"), PromptVersion: "meeting_summary_v1", Model: result.Usage.Model, }); err != nil { writeStoreError(w, r, err) return } _ = st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, summaryID, map[string]any{ "summary_id": summaryID, "meeting_id": meetingID, "conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems, }, "", "") writeSuccess(w, r, map[string]any{"task_id": taskID, "meeting_id": meetingID, "status": "succeeded", "polling_interval_ms": 1500}) } func (a *API) aiMeetingSummaryUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { if _, ok := requireIdempotencyKey(w, r); !ok { return } var body aiMeetingSummaryUpdateRequest if !decodeBody(w, r, &body) { return } summaryID := strings.TrimSpace(body.SummaryID) if summaryID == "" { writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "summary_id"}) return } summary, err := st.UpdateAIMeetingSummary(r.Context(), p.UserID, meetingID, summaryID, mustJSON(body.Conclusions, "[]"), mustJSON(body.Decisions, "[]"), mustJSON(body.ActionItems, "[]")) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, aiMeetingSummaryPayload(summary)) } func (a *API) workbenchApps(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } modules, _, err := st.WorkbenchModules(r.Context(), p.UserID) if err != nil { writeStoreError(w, r, err) return } apps := workbenchAppPayloads(modules) caps, err := st.AICapabilities(r.Context()) if err == nil { apps = append(apps, aiAppPayloads(caps)...) } writeSuccess(w, r, map[string]any{"common_apps": apps, "all_apps": apps}) } func (a *API) workbenchAppsSearch(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } query := strings.TrimSpace(r.URL.Query().Get("q")) if query == "" { writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "q"}) return } modules, _, err := st.WorkbenchModules(r.Context(), p.UserID) if err != nil { writeStoreError(w, r, err) return } apps := workbenchAppPayloads(modules) if caps, err := st.AICapabilities(r.Context()); err == nil { apps = append(apps, aiAppPayloads(caps)...) } items := make([]map[string]any, 0) for _, app := range apps { name, _ := app["name"].(string) if strings.Contains(strings.ToLower(name), strings.ToLower(query)) { app["matched_text"] = name items = append(items, app) } } writeSuccess(w, r, map[string]any{"items": items, "has_more": false}) } func (a *API) writeAIError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, ai.ErrNotConfigured): writeError(w, r, http.StatusServiceUnavailable, 30050, reasonAINotConfigured, "ai provider not configured", nil) case errors.Is(err, ai.ErrTimeout): writeError(w, r, http.StatusGatewayTimeout, 30051, reasonAITimeout, "ai timeout", nil) case errors.Is(err, ai.ErrBadOutput): writeError(w, r, http.StatusBadGateway, 30052, reasonAITaskFailed, "ai task failed", nil) default: writeError(w, r, http.StatusBadGateway, 30053, reasonAIProviderFailed, "ai provider failed", nil) } } func aiErrorReason(err error) string { switch { case errors.Is(err, ai.ErrNotConfigured): return reasonAINotConfigured case errors.Is(err, ai.ErrTimeout): return reasonAITimeout case errors.Is(err, ai.ErrBadOutput): return reasonAITaskFailed default: return reasonAIProviderFailed } } func (a *API) requireAICapability(w http.ResponseWriter, r *http.Request, st *store.Store, feature string) (store.AICapability, bool) { caps, err := st.AICapabilities(r.Context()) if err != nil { writeStoreError(w, r, err) return store.AICapability{}, false } for _, cap := range caps { if cap.Feature != feature { continue } if !cap.Enabled { writeError(w, r, http.StatusForbidden, 30055, reasonAIFeatureClosed, "ai feature disabled", map[string]any{"feature": feature}) return store.AICapability{}, false } return cap, true } writeError(w, r, http.StatusForbidden, 30055, reasonAIFeatureClosed, "ai feature disabled", map[string]any{"feature": feature}) return store.AICapability{}, false } func aiPolishFeature(scene string) string { switch strings.TrimSpace(scene) { case "daily_report": return "daily_report_polish" case "monthly_report": return "monthly_report_polish" default: return "text_polish" } } func aiMaxInputChars(cap store.AICapability, fallback int) int { if cap.MaxInputChars > 0 { return cap.MaxInputChars } return fallback } type aiPolishRequest struct { SourceText string `json:"source_text"` Scene string `json:"scene"` Tone string `json:"tone"` MaxOutputChars int `json:"max_output_chars"` } type aiTableExportRequest struct { Format string `json:"format"` } type aiDocumentGenerateRequest struct { SourceText string `json:"source_text"` TitleHint string `json:"title_hint"` DocumentType string `json:"document_type"` Language string `json:"language"` } type aiDocumentSaveRequest struct { TaskID string `json:"task_id"` Title string `json:"title"` DocumentType string `json:"document_type"` ContentMarkdown string `json:"content_markdown"` ContentPlain string `json:"content_plain"` Sections []ai.DocumentSection `json:"sections"` } type aiTableAnalysisRequest struct { FileID string `json:"file_id"` FileName string `json:"file_name"` FileType string `json:"file_type"` FileSize int64 `json:"file_size"` Topic string `json:"topic"` SheetName string `json:"sheet_name"` Language string `json:"language"` } type aiMeetingSummaryRequest struct { FileIDs []string `json:"file_ids"` SummaryType string `json:"summary_type"` ForceRegenerate bool `json:"force_regenerate"` } type aiMeetingSummaryUpdateRequest struct { SummaryID string `json:"summary_id"` Conclusions []string `json:"conclusions"` Decisions []string `json:"decisions"` ActionItems []ai.MeetingActionItem `json:"action_items"` } func aiCapabilityPayloads(caps []store.AICapability) []map[string]any { items := make([]map[string]any, 0, len(caps)) for _, cap := range caps { cfg := jsonObject(cap.ConfigJSON) title, _ := cfg["entry_title"].(string) items = append(items, map[string]any{ "feature": cap.Feature, "enabled": cap.Enabled, "entry_title": firstNonEmpty(title, cap.Feature), "daily_limit": cap.DailyLimit, "remaining_today": cap.DailyLimit, "max_input_chars": cap.MaxInputChars, "max_file_size_mb": cap.MaxFileSizeMB, "model": cap.Model, }) } return items } func anyCapabilityEnabled(caps []store.AICapability) bool { for _, cap := range caps { if cap.Enabled { return true } } return false } func aiTaskPayload(task *store.AITask) map[string]any { var result any if task.ResultJSON.Valid && strings.TrimSpace(task.ResultJSON.String) != "" { var decoded any if json.Unmarshal([]byte(task.ResultJSON.String), &decoded) == nil { result = decoded } } var errPayload any if task.ErrorReason != "" { errPayload = map[string]any{"reason": task.ErrorReason, "message": task.ErrorMessage} } return map[string]any{ "task_id": task.TaskID, "feature": task.Feature, "status": task.Status, "progress": task.Progress, "steps": []any{}, "result": result, "error": errPayload, } } func aiRecordPayloads(records []store.AIRecord) []map[string]any { items := make([]map[string]any, 0, len(records)) for _, record := range records { items = append(items, map[string]any{ "record_id": record.RecordID, "title": record.Title, "summary": record.Summary, "document_type": "general", "status": record.Status, "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), }) } return items } func aiTableRecordPayloads(records []store.AIRecord) []map[string]any { items := make([]map[string]any, 0, len(records)) for _, record := range records { items = append(items, map[string]any{ "record_id": record.RecordID, "title": record.Title, "summary": record.Summary, "status": record.Status, "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), }) } return items } func aiGenericRecordPayloads(records []store.AIRecord) []map[string]any { items := make([]map[string]any, 0, len(records)) for _, record := range records { items = append(items, map[string]any{ "record_id": record.RecordID, "type": record.RecordType, "title": record.Title, "summary": record.Summary, "status": record.Status, "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), "route": aiRecordRoute(record), }) } return items } func aiRecordRoute(record store.AIRecord) string { switch record.RecordType { case "smart_document": return "/workbench/ai/document/" + record.RecordID case "smart_table": return "/workbench/ai/table/" + record.RecordID default: return "" } } func aiDocumentPayload(doc *store.AIDocument) map[string]any { var sections any = []any{} if strings.TrimSpace(doc.SectionsJSON) != "" { var decoded any if json.Unmarshal([]byte(doc.SectionsJSON), &decoded) == nil { sections = decoded } } return map[string]any{ "record_id": doc.RecordID, "title": doc.Title, "document_type": doc.DocumentType, "content_markdown": doc.ContentMarkdown, "content_plain": doc.ContentPlain, "sections": sections, "source_excerpt": doc.SourceExcerpt, "created_at": doc.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": doc.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func aiTableAnalysisPayload(item *store.AITableAnalysis) map[string]any { return map[string]any{ "record_id": item.RecordID, "title": item.Topic, "file": map[string]any{ "file_id": item.FileID, "file_name": item.FileName, "file_type": item.FileType, "file_size": item.FileSize, }, "metrics": decodeJSONAny(item.MetricsJSON, []any{}), "preview": decodeJSONAny(item.PreviewJSON, map[string]any{}), "alerts": decodeJSONAny(item.AlertsJSON, []any{}), "interpretation": item.Interpretation, "export_options": []string{"docx"}, "created_at": item.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func aiMeetingSummaryPayload(item *store.AIMeetingSummary) map[string]any { payload := map[string]any{ "meeting_id": item.MeetingID, "summary_id": item.SummaryID, "status": "succeeded", "conclusions": decodeJSONAny(item.ConclusionsJSON, []any{}), "decisions": decodeJSONAny(item.DecisionsJSON, []any{}), "action_items": decodeJSONAny(item.ActionItemsJSON, []any{}), "editable": true, "version": item.Version, "updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano), } if item.EditedAt != nil { payload["edited_at"] = item.EditedAt.UTC().Format(time.RFC3339Nano) payload["edited_by"] = item.EditedBy } return payload } func meetingSummaryFiles(files []store.MeetingNoteFile, requested []string) ([]string, []string) { filter := map[string]bool{} for _, id := range requested { id = strings.TrimSpace(id) if id != "" { filter[id] = true } } names := make([]string, 0, len(files)) ids := make([]string, 0, len(files)) for _, file := range files { if len(filter) > 0 && !filter[file.FileID] { continue } names = append(names, file.FileName) ids = append(ids, file.FileID) } return names, ids } func meetingSummaryParticipants(meeting store.Meeting) []string { items := make([]string, 0, len(meeting.Participants)+1) if meeting.Organizer.DisplayName != "" { items = append(items, meeting.Organizer.DisplayName) } for _, participant := range meeting.Participants { name := participant.User.DisplayName if name == "" { name = formatUserID(participant.UserID) } items = append(items, name) } return items } func resolveTableFilePath(fileID, location string) string { candidates := []string{strings.TrimSpace(location), strings.TrimSpace(fileID)} for _, candidate := range candidates { if candidate == "" || strings.HasPrefix(candidate, "http://") || strings.HasPrefix(candidate, "https://") { continue } if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate } } return "" } func tableProfileForAI(path, fileType string) ai.TableProfile { if strings.TrimSpace(path) == "" { return ai.TableProfile{Parsed: false, Message: "source file is not available locally"} } profile, err := tableparse.Parse(path, fileType) if err != nil { return ai.TableProfile{Parsed: false, Message: err.Error()} } return ai.TableProfile{ Columns: profile.Columns, SampleRows: profile.SampleRows, RowCount: profile.RowCount, Stats: profile.Stats, Outliers: profile.Outliers, Parsed: profile.Parsed, Message: profile.Message, } } func allowedTableFileType(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "xlsx", "xls", "csv": return true default: return false } } func aiTableAnalysisDocx(item *store.AITableAnalysis) ([]byte, error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) files := map[string]string{ "[Content_Types].xml": ` `, "_rels/.rels": ` `, "docProps/app.xml": ` IM Workbench`, "docProps/core.xml": aiDocxCoreXML(item), "word/document.xml": aiTableAnalysisDocumentXML(item), } for name, content := range files { w, err := zw.Create(name) if err != nil { return nil, err } if _, err := w.Write([]byte(content)); err != nil { return nil, err } } if err := zw.Close(); err != nil { return nil, err } return buf.Bytes(), nil } func aiTableAnalysisDocumentXML(item *store.AITableAnalysis) string { var b strings.Builder b.WriteString(``) b.WriteString(``) docxParagraph(&b, firstNonEmpty(item.Topic, "智能表格解读报告"), true) docxParagraph(&b, "文件信息", true) docxParagraph(&b, "文件名:"+item.FileName, false) docxParagraph(&b, "文件类型:"+item.FileType, false) docxParagraph(&b, "文件大小:"+strconv.FormatInt(item.FileSize, 10)+" bytes", false) docxParagraph(&b, "核心指标", true) for _, metric := range aiExportList(item.MetricsJSON) { docxParagraph(&b, "- "+aiExportMetricText(metric), false) } docxParagraph(&b, "异常提醒", true) for _, alert := range aiExportList(item.AlertsJSON) { docxParagraph(&b, "- "+aiExportAlertText(alert), false) } docxParagraph(&b, "核心解读", true) for _, line := range strings.Split(strings.TrimSpace(item.Interpretation), "\n") { if strings.TrimSpace(line) != "" { docxParagraph(&b, strings.TrimSpace(line), false) } } b.WriteString(``) b.WriteString(``) return b.String() } func docxParagraph(b *strings.Builder, text string, bold bool) { b.WriteString(``) if bold { b.WriteString(``) } b.WriteString(``) _ = xml.EscapeText(b, []byte(text)) b.WriteString(``) } func aiDocxCoreXML(item *store.AITableAnalysis) string { created := item.CreatedAt.UTC().Format(time.RFC3339) title := xmlEscape(firstNonEmpty(item.Topic, "智能表格解读报告")) return ` ` + title + `IM WorkbenchIM Workbench` + created + `` + time.Now().UTC().Format(time.RFC3339) + `` } func aiExportList(raw string) []map[string]any { var items []map[string]any if err := json.Unmarshal([]byte(raw), &items); err != nil { return nil } return items } func aiExportMetricText(item map[string]any) string { return strings.TrimSpace(fmt.Sprintf("%v:%v(%v,%v)", item["name"], item["value"], item["change_text"], item["trend"])) } func aiExportAlertText(item map[string]any) string { return strings.TrimSpace(fmt.Sprintf("[%v] %v:%v", item["level"], item["title"], item["description"])) } func aiTableExportFileName(item *store.AITableAnalysis) string { name := strings.TrimSpace(firstNonEmpty(item.Topic, item.RecordID, "智能表格解读报告")) replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") name = replacer.Replace(name) if len([]rune(name)) > 80 { name = string([]rune(name)[:80]) } return name + "-解读报告.docx" } func absoluteURL(r *http.Request, path string) string { scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) if scheme == "" { if r.TLS != nil { scheme = "https" } else { scheme = "http" } } host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) if host == "" { host = r.Host } return scheme + "://" + host + path } func contentDispositionAttachment(fileName string) string { escaped := url.QueryEscape(fileName) return `attachment; filename="report.docx"; filename*=UTF-8''` + escaped } func xmlEscape(value string) string { var b strings.Builder _ = xml.EscapeText(&b, []byte(value)) return b.String() } func mustJSON(value any, fallback string) string { data, err := json.Marshal(value) if err != nil || len(data) == 0 || string(data) == "null" { return fallback } return string(data) } func decodeJSONAny(raw string, fallback any) any { if strings.TrimSpace(raw) == "" { return fallback } var out any if err := json.Unmarshal([]byte(raw), &out); err != nil { return fallback } return out } func workbenchAppPayloads(modules []store.WorkbenchModule) []map[string]any { items := make([]map[string]any, 0, len(modules)) for _, module := range modules { items = append(items, map[string]any{ "app_id": module.Code, "name": module.Name, "icon": module.IconFileID, "route": module.Route, "type": "builtin", "enabled": module.Enabled, }) } return items } func aiAppPayloads(caps []store.AICapability) []map[string]any { routes := map[string]string{ "smart_document": "/workbench/ai/document", "smart_table": "/workbench/ai/table", } items := make([]map[string]any, 0, len(caps)) for _, cap := range caps { route := routes[cap.Feature] if route == "" { continue } cfg := jsonObject(cap.ConfigJSON) title, _ := cfg["entry_title"].(string) items = append(items, map[string]any{ "app_id": cap.Feature, "name": firstNonEmpty(title, cap.Feature), "icon": "ai", "route": route, "type": "ai", "enabled": cap.Enabled, }) } return items } func hashText(text string) string { sum := sha256.Sum256([]byte(text)) return hex.EncodeToString(sum[:]) } func excerpt(text string, maxRunes int) string { runes := []rune(strings.TrimSpace(text)) if len(runes) <= maxRunes { return string(runes) } return string(runes[:maxRunes]) }