Files
olons_server/internal/httpapi/ai.go
T

1215 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>`,
"docProps/app.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>IM Workbench</Application></Properties>`,
"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(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
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(`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr>`)
b.WriteString(`</w:body></w:document>`)
return b.String()
}
func docxParagraph(b *strings.Builder, text string, bold bool) {
b.WriteString(`<w:p><w:r>`)
if bold {
b.WriteString(`<w:rPr><w:b/></w:rPr>`)
}
b.WriteString(`<w:t>`)
_ = xml.EscapeText(b, []byte(text))
b.WriteString(`</w:t></w:r></w:p>`)
}
func aiDocxCoreXML(item *store.AITableAnalysis) string {
created := item.CreatedAt.UTC().Format(time.RFC3339)
title := xmlEscape(firstNonEmpty(item.Topic, "智能表格解读报告"))
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>` + title + `</dc:title><dc:creator>IM Workbench</dc:creator><cp:lastModifiedBy>IM Workbench</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">` + created + `</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">` + time.Now().UTC().Format(time.RFC3339) + `</dcterms:modified></cp:coreProperties>`
}
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])
}