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)
snapshot, err := aiTaskSnapshot(aiDocumentTaskSnapshot{
SourceText: source,
TitleHint: strings.TrimSpace(body.TitleHint),
DocumentType: strings.TrimSpace(body.DocumentType),
Language: strings.TrimSpace(body.Language),
})
if err != nil {
writeStoreError(w, r, err)
return
}
if err := st.CreateAITask(r.Context(), store.AITask{
TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "smart_document",
Status: "queued", Progress: 0, SourceType: "text", InputHash: hashText(source),
InputSnapshot: snapshot,
PromptVersion: "smart_document_v1", Model: "",
}, nil); err != nil {
writeStoreError(w, r, err)
return
}
writeSuccess(w, r, map[string]any{
"task_id": taskID,
"status": "queued",
"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
}
feature := ""
if task, err := st.AITask(r.Context(), p.UserID, taskID); err == nil {
feature = task.Feature
}
a.publishAITaskEvent(r.Context(), p.TenantID, p.TenantCode, p.UserID, taskID, "ai", "AI 任务已取消", "", map[string]any{
"task_id": taskID, "feature": feature, "status": "canceled", "progress": 100, "result_type": "none",
})
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)
snapshot, err := aiTaskSnapshot(aiTableTaskSnapshot{
FileID: fileID, FileName: strings.TrimSpace(body.FileName), FileType: fileType, FileSize: fileSize,
Topic: topic, SheetName: strings.TrimSpace(body.SheetName), Language: strings.TrimSpace(body.Language), RecordID: recordID,
})
if err != nil {
writeStoreError(w, r, err)
return
}
if err := st.CreateAITask(r.Context(), store.AITask{
TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "smart_table",
Status: "queued", Progress: 0, SourceType: "file", SourceRefID: fileID, InputHash: hashText(fileID + ":" + topic),
InputSnapshot: snapshot, PromptVersion: "smart_table_v1",
}, nil); err != nil {
writeStoreError(w, r, err)
return
}
writeSuccess(w, r, map[string]any{"task_id": taskID, "record_id": recordID, "status": "queued", "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
}
if _, err := st.Meeting(r.Context(), p.UserID, meetingID); err != nil {
writeStoreError(w, r, err)
return
}
files, err := st.MeetingNoteFiles(r.Context(), p.UserID, meetingID)
if err != nil {
writeStoreError(w, r, err)
return
}
_, fileIDs := meetingSummaryFiles(files, body.FileIDs)
taskID := "aitask_mtg_" + randomToken(12)
summaryID := "aisum_" + randomToken(10)
snapshot, err := aiTaskSnapshot(aiMeetingSummaryTaskSnapshot{
MeetingID: meetingID, SummaryID: summaryID, FileIDs: fileIDs,
SummaryType: strings.TrimSpace(body.SummaryType), ForceRegenerate: body.ForceRegenerate,
})
if err != nil {
writeStoreError(w, r, err)
return
}
if err := st.CreateAITask(r.Context(), store.AITask{
TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "meeting_summary",
Status: "queued", Progress: 0, SourceType: "meeting", SourceRefID: meetingID, InputHash: hashText(meetingID + strings.Join(fileIDs, ",")),
InputSnapshot: snapshot, PromptVersion: "meeting_summary_v1",
}, nil); err != nil {
writeStoreError(w, r, err)
return
}
writeSuccess(w, r, map[string]any{"task_id": taskID, "meeting_id": meetingID, "summary_id": summaryID, "status": "queued", "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 {
fileType := strings.ToLower(strings.TrimSpace(record.FileType))
items = append(items, map[string]any{
"record_id": record.RecordID,
"title": record.Title,
"summary": record.Summary,
"format": fileType,
"file_type": fileType,
"file_name": record.FileName,
"file_size": record.FileSize,
"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 (a *API) tableProfileForAI(r *http.Request, fileID string, meta store.AIFileMetadata, fileType string) ai.TableProfile {
if path := resolveTableFilePath(fileID, meta.Location); path != "" {
return tableProfileFromPath(path, fileType)
}
if path, cleanup, err := downloadWorkbenchFileToTemp(r, fileID, fileType); err == nil {
defer cleanup()
return tableProfileFromPath(path, fileType)
}
if strings.TrimSpace(meta.Location) == "" {
return ai.TableProfile{Parsed: false, Message: "source file metadata is not available"}
}
path, cleanup, err := a.downloadOSSObjectToTemp(r.Context(), meta.Location, fileType)
if err != nil {
return ai.TableProfile{Parsed: false, Message: "source file is not available: " + err.Error()}
}
defer cleanup()
return tableProfileFromPath(path, fileType)
}
func tableProfileFromPath(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])
}