From 47a10c93bece79ff684e128a6a0c1dc55ff806f0 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 30 Sep 2026 10:02:00 +0800 Subject: [PATCH] fix: improve ai table file handling --- docs/IM工作台AI前后端对接文档.md | 180 ++++++++++++++++++++++++++++-- internal/httpapi/ai.go | 28 ++++- internal/httpapi/ai_export_oss.go | 95 ++++++++++++++++ internal/store/ai.go | 32 +++++- internal/tableparse/tableparse.go | 14 ++- 5 files changed, 334 insertions(+), 15 deletions(-) diff --git a/docs/IM工作台AI前后端对接文档.md b/docs/IM工作台AI前后端对接文档.md index 0b26128..7e3d36a 100644 --- a/docs/IM工作台AI前后端对接文档.md +++ b/docs/IM工作台AI前后端对接文档.md @@ -132,6 +132,15 @@ https://{host}/v1/workbench/ai/* | `analyze_trends` | 分析趋势变化 | | `generate_report` | 生成解读报告 | +#### 0.4.6 AI 推送结果类型 + +| 枚举 | 说明 | +| --- | --- | +| `text` | 文本结果,适用于 AI 美文、日报/月报润色、较短的智能文档草稿 | +| `file` | 文件结果,适用于导出 docx、pdf 等文件;推送中返回文件服务下载地址 | +| `record` | 记录结果,适用于智能表格、智能文档等已生成记录的能力 | +| `summary` | 会议总结类结果,挂在会议下展示 | + ### 0.5 通用错误码 | reason | HTTP | 说明 | 前端处理 | @@ -161,6 +170,8 @@ https://{host}/v1/workbench/ai/* | 弱网重试 | 写接口使用同一个 `Idempotency-Key` 重试 | | 取消任务 | 只允许取消 `queued`、`processing` 状态任务 | | 记录保存 | 智能文档生成成功后,必须显式保存才进入文档记录;智能表格解读成功后默认生成记录 | +| 在线推送 | AI 任务完成后仅推送当前在线用户;离线用户不补推,上线后通过任务或记录接口重新拉取 | +| 推送兜底 | 前端接入推送后仍需保留轮询和页面恢复拉取,推送只作为实时体验优化 | ## 1. 工作台 AI 入口与应用搜索 @@ -1046,6 +1057,23 @@ Idempotency-Key: ai_cancel_01K8ZC3FZ6BTX9Z1SK8HV7QYWM | `cursor` | string | 否 | 分页游标 | | `limit` | number | 否 | 默认 20,最大 50 | +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `items[].record_id` | string | 解读记录 ID | +| `items[].title` | string | 解读报告标题 | +| `items[].summary` | string | 解读摘要,列表页可展示前几行 | +| `items[].format` | string | 展示用文件格式,取值为小写文件后缀,例如 `xlsx`、`xls`、`csv` | +| `items[].file_type` | string | 原始文件类型,当前与 `format` 一致,保留给前端逻辑判断 | +| `items[].file_name` | string | 原始文件名 | +| `items[].file_size` | number | 原始文件大小,单位 byte | +| `items[].status` | string | 解读状态,见 0.4.2 | +| `items[].created_at` | string | 创建时间,UTC RFC3339 | +| `items[].updated_at` | string | 更新时间,UTC RFC3339 | +| `next_cursor` | string | 下一页游标,为空表示无下一页 | +| `has_more` | boolean | 是否还有下一页 | + #### 出参示例 ```json @@ -1056,10 +1084,14 @@ Idempotency-Key: ai_cancel_01K8ZC3FZ6BTX9Z1SK8HV7QYWM { "record_id": "airec_tbl_6LP0nM9s3B", "title": "8月销售数据", + "summary": "本次报表展示 8 月销售数据的核心指标、异常波动和区域表现。", + "format": "xlsx", "file_name": "sales_august.xlsx", "file_type": "xlsx", + "file_size": 18146135, "status": "succeeded", - "created_at": "2026-09-25T08:42:00.000Z" + "created_at": "2026-09-25T08:42:00.000Z", + "updated_at": "2026-09-25T08:42:00.000Z" } ], "next_cursor": "", @@ -1534,31 +1566,150 @@ X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWV | 项 | 内容 | | --- | --- | -| 状态 | `可后续扩展` | -| 事件 | `workbench.ai.task_updated` | -| 用途 | 服务端主动推送 AI 任务状态变化,减少轮询 | +| 状态 | `建议接入` | +| 事件 | `workbench.ai.task.updated` | +| 用途 | 服务端主动推送 AI 任务完成、失败或取消状态,减少前台等待 | | 鉴权 | 复用 IM WebSocket 登录态 | -#### 事件示例 +#### 推送边界 + +1. 仅推送在线用户。用户离线时服务端不保存离线 AI 推送、不补发历史推送。 +2. 用户重新上线、重新进入工作台 AI 页面时,前端通过任务详情、记录列表或业务详情接口恢复状态。 +3. 推送内容只放任务状态、结果索引和轻量结果;完整长文本、大对象和权限敏感内容仍以 HTTP 详情接口为准。 +4. 文件结果不推裸 OSS 签名地址,统一推 IM 文件服务稳定地址:`/v0/file/s/{file_id}` 或其绝对 URL。 +5. 推送丢失、WebSocket 断线、App 切后台等场景下,前端必须保留轮询兜底。 + +#### 通用事件结构 ```json { - "topic": "workbench.ai.task_updated", + "topic": "workbench.ai.task.updated", "data": { "task_id": "aitask_tbl_2Fh9aQx73K", "feature": "smart_table", "status": "succeeded", "progress": 100, + "result_type": "record", "record_id": "airec_tbl_6LP0nM9s3B", + "result": { + "title": "8月销售数据解读", + "summary": "销售额整体平稳,建议重点关注渠道结构和异常订单。" + }, "updated_at": "2026-09-25T08:42:00.000Z" } } ``` +#### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `task_id` | string | 是 | AI 任务 ID | +| `feature` | string | 是 | AI 能力类型,见 0.4.1 | +| `status` | string | 是 | `succeeded`、`failed`、`canceled` 为主要推送状态 | +| `progress` | number | 否 | 进度,完成态一般为 `100` | +| `result_type` | string | 否 | `text`、`file`、`record`、`summary` | +| `record_id` | string | 否 | 记录类结果 ID | +| `meeting_id` | string | 否 | 会议总结所属会议 ID | +| `summary_id` | string | 否 | 会议总结 ID | +| `result` | object | 否 | 轻量结果体,按 result_type 区分 | +| `error` | object | 否 | 失败信息,包含 `reason`、`message` | +| `updated_at` | string | 是 | 更新时间,UTC RFC3339 | + +#### result 示例 + +文本结果: + +```json +{ + "result_type": "text", + "result": { + "text": "润色后的文本内容", + "scene": "daily_report_polish" + } +} +``` + +文件结果: + +```json +{ + "result_type": "file", + "result": { + "file_id": "IHC4k7rlpo0.docx", + "file_name": "阿德勒-解读报告.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "file_size": 2870, + "download_url": "https://api.118im.com/v0/file/s/IHC4k7rlpo0.docx" + } +} +``` + +记录结果: + +```json +{ + "result_type": "record", + "record_id": "airec_tbl_6LP0nM9s3B", + "result": { + "title": "8月销售数据解读", + "summary": "销售额整体平稳,建议关注渠道结构。" + } +} +``` + +会议总结结果: + +```json +{ + "result_type": "summary", + "meeting_id": "mtg_7YUHoLTqrl4ZO_G6", + "summary_id": "aisum_3Rn8qU2b6X", + "result": { + "conclusion_count": 3, + "action_item_count": 2 + } +} +``` + +失败结果: + +```json +{ + "task_id": "aitask_tbl_2Fh9aQx73K", + "feature": "smart_table", + "status": "failed", + "progress": 100, + "error": { + "reason": "AI_TASK_FAILED", + "message": "生成失败,请重试" + }, + "updated_at": "2026-09-25T08:42:00.000Z" +} +``` + +#### 各接口推送建议 + +| 接口/能力 | 是否异步 | 是否推送 | result_type | 推送内容建议 | +| --- | --- | --- | --- | --- | +| `POST /v1/workbench/ai/documents/generate` 智能文档生成 | 是 | 是 | `text` 或 `record` | 内容较短可直接推 `title`、`content_plain`、`content_markdown`;内容较长只推 `task_id` 和摘要,前端拉任务详情 | +| `POST /v1/workbench/ai/documents` 保存文档 | 否 | 否 | - | 用户主动保存,HTTP 返回即可 | +| `POST /v1/workbench/ai/table-analyses` 智能表格解读 | 是 | 是 | `record` | 推 `record_id`、标题、摘要;前端可直接展示完成卡片,点开时拉详情 | +| `POST /v1/workbench/ai/table-analyses/{record_id}/export` 导出报告 | 可异步 | 是 | `file` | 推 `file_id`、`file_name`、`mime_type`、`file_size`、`download_url` | +| `POST /v1/workbench/ai/text/polish` AI 美文 | 可同步;超过 2 秒建议异步 | 异步时推 | `text` | 推润色后的 `text`,内容过长时只推摘要并拉任务详情 | +| 日报 AI 润色 | 可同步;超过 2 秒建议异步 | 异步时推 | `text` | 推润色后的日报内容或草稿 ID | +| 月报 AI 润色 | 可同步;超过 2 秒建议异步 | 异步时推 | `text` | 推 `work_summary`、`next_plan` 或草稿 ID | +| `POST /v1/workbench/meetings/{meeting_id}/ai-summary` 会议 AI 总结 | 是 | 是 | `summary` | 推 `meeting_id`、`summary_id`、结论数、待办数;完整内容拉详情 | +| `GET /v1/workbench/ai/tasks/{task_id}` 查询任务 | 否 | 否 | - | 前端主动拉取,作为推送兜底 | +| `POST /v1/workbench/ai/tasks/{task_id}/cancel` 取消任务 | 否 | 可选 | - | 多端在线时可推 `status=canceled`;单端场景可只依赖 HTTP 返回 | + #### 对接重点 -1. 首版建议优先使用 HTTP 轮询,WebSocket 事件作为后续优化。 -2. 即使接入事件,前端仍需保留轮询兜底。 +1. 推送用于“在线即时更新”,不是最终数据源;最终状态以 HTTP 查询为准。 +2. 前端收到 `succeeded` 后,可优先使用推送中的轻量结果更新 UI;需要完整内容时再拉详情。 +3. 前端收到 `file` 类型结果后,可直接展示下载按钮,下载地址使用 `download_url`。 +4. 前端收到 `failed` 后展示失败并允许重试,不展示供应商原始错误。 +5. App 启动、WebSocket 重连、进入 AI 页面时,前端重新拉任务或记录列表恢复离线期间完成的任务。 ## 7. 对接重难点 @@ -1568,7 +1719,18 @@ AI 接口只接受 IM 登录态,不单独做 AI 登录。租户、用户、部 ### 7.2 异步任务与用户体验 -智能文档、智能表格、会议 AI 总结都可能耗时。前端必须处理 `queued`、`processing`、`succeeded`、`failed`、`canceled`,并在用户离开页面后可通过记录或详情恢复结果。 +智能文档、智能表格、会议 AI 总结都可能耗时。前端必须处理 `queued`、`processing`、`succeeded`、`failed`、`canceled`,并在用户离开页面后可通过任务、记录或业务详情恢复结果。 + +推荐交互流程: + +1. 创建任务接口立即返回 `task_id`、`status=queued`、`polling_interval_ms`,前端进入 loading 或后台任务状态。 +2. 用户在线时,服务端通过 IM WebSocket 推送 `workbench.ai.task.updated`,前端收到完成事件后直接刷新 UI。 +3. 用户离线、App 切后台、WebSocket 断线时,服务端不补发离线 AI 推送;前端重新上线后主动拉任务详情、AI 记录列表或业务详情。 +4. 推送结果为 `text` 时,短文本可直接展示;长文本以 HTTP 任务详情为准。 +5. 推送结果为 `file` 时,前端直接使用 `download_url` 展示下载入口;该地址应为 IM 文件服务稳定地址,不是裸 OSS 签名地址。 +6. 推送结果为 `record` 或 `summary` 时,前端可先展示标题和摘要,点开详情时再请求对应详情接口。 +7. 同一账号多端在线时,所有在线端都可以收到任务完成推送;各端仍需按页面状态决定是否自动展示或只更新角标。 +8. 失败、取消也应更新本地任务状态;失败文案按 `error.reason` 做本地化,不展示供应商原始报错。 ### 7.3 文件服务与 AI 服务衔接 diff --git a/internal/httpapi/ai.go b/internal/httpapi/ai.go index b42ed9f..709d3ef 100644 --- a/internal/httpapi/ai.go +++ b/internal/httpapi/ai.go @@ -379,7 +379,7 @@ func (a *API) aiTableAnalysisCreate(w http.ResponseWriter, r *http.Request, p *a writeStoreError(w, r, err) return } - profile := tableProfileForAI(resolveTableFilePath(fileID, fileMeta.Location), fileType) + profile := a.tableProfileForAI(r, fileID, fileMeta, 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, }) @@ -831,10 +831,15 @@ func aiRecordPayloads(records []store.AIRecord) []map[string]any { 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), @@ -976,7 +981,26 @@ func resolveTableFilePath(fileID, location string) string { return "" } -func tableProfileForAI(path, fileType string) ai.TableProfile { +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"} } diff --git a/internal/httpapi/ai_export_oss.go b/internal/httpapi/ai_export_oss.go index e46fff6..0e17298 100644 --- a/internal/httpapi/ai_export_oss.go +++ b/internal/httpapi/ai_export_oss.go @@ -8,7 +8,10 @@ import ( "encoding/base64" "encoding/binary" "fmt" + "io" "net/http" + "net/url" + "os" "path" "strconv" "strings" @@ -88,6 +91,98 @@ func (a *API) uploadAITableAnalysisExport(ctx context.Context, r *http.Request, }, nil } +func downloadWorkbenchFileToTemp(r *http.Request, fileID, fileType string) (string, func(), error) { + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return "", func() {}, fmt.Errorf("empty file id") + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, absoluteURL(r, "/v0/file/s/"+url.PathEscape(fileID)), nil) + if err != nil { + return "", func() {}, err + } + copyHeader(req, r, "Authorization") + copyHeader(req, r, "X-MeetingIM-APIKey") + copyHeader(req, r, "X-Tenant-Code") + copyHeader(req, r, "X-IM-Schema") + copyHeader(req, r, "X-Request-ID") + if auth := strings.TrimSpace(r.Header.Get("X-MeetingIM-Auth")); auth != "" { + req.Header.Set("X-MeetingIM-Auth", auth) + } else if token := bearerTokenFromHeader(r.Header.Get("Authorization")); token != "" { + req.Header.Set("X-MeetingIM-Auth", "token "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", func() {}, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", func() {}, fmt.Errorf("file service returned %d", resp.StatusCode) + } + return responseBodyToTemp(resp.Body, fileType) +} + +func (a *API) downloadOSSObjectToTemp(ctx context.Context, objectKey, fileType string) (string, func(), error) { + objectKey = strings.Trim(strings.TrimSpace(objectKey), "/") + if objectKey == "" { + return "", func() {}, fmt.Errorf("empty object key") + } + cfg, err := a.ossUploadConfig(ctx) + if err != nil { + return "", func() {}, err + } + client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret) + if err != nil { + return "", func() {}, err + } + bucket, err := client.Bucket(cfg.Bucket) + if err != nil { + return "", func() {}, err + } + reader, err := bucket.GetObject(objectKey) + if err != nil { + return "", func() {}, err + } + defer reader.Close() + return responseBodyToTemp(reader, fileType) +} + +func responseBodyToTemp(reader io.Reader, fileType string) (string, func(), error) { + suffix := "." + strings.TrimLeft(strings.ToLower(strings.TrimSpace(fileType)), ".") + if suffix == "." { + suffix = ".table" + } + tmp, err := os.CreateTemp("", "im-workbench-ai-table-*"+suffix) + if err != nil { + return "", func() {}, err + } + tmpPath := tmp.Name() + cleanup := func() { _ = os.Remove(tmpPath) } + if _, err := io.Copy(tmp, reader); err != nil { + _ = tmp.Close() + cleanup() + return "", func() {}, err + } + if err := tmp.Close(); err != nil { + cleanup() + return "", func() {}, err + } + return tmpPath, cleanup, nil +} + +func copyHeader(dst *http.Request, src *http.Request, key string) { + if value := strings.TrimSpace(src.Header.Get(key)); value != "" { + dst.Header.Set(key, value) + } +} + +func bearerTokenFromHeader(value string) string { + token, ok := strings.CutPrefix(strings.TrimSpace(value), "Bearer ") + if !ok { + return "" + } + return strings.TrimSpace(token) +} + func (a *API) ossUploadConfig(ctx context.Context) (ossUploadConfig, error) { values, err := managerconfig.New(a.manager).ThirdPartyConfig(ctx, "im_third_party", "oss_enabled", diff --git a/internal/store/ai.go b/internal/store/ai.go index c3a70f8..75c060e 100644 --- a/internal/store/ai.go +++ b/internal/store/ai.go @@ -53,6 +53,9 @@ type AIRecord struct { Status string `db:"status"` DetailID string `db:"detail_id"` TaskID string `db:"task_id"` + FileName string `db:"file_name"` + FileType string `db:"file_type"` + FileSize int64 `db:"file_size"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` DeletedAt *time.Time `db:"deleted_at"` @@ -356,7 +359,34 @@ func (s *Store) UpdateAITableAnalysisExportFileID(ctx context.Context, userID in } func (s *Store) AITableAnalyses(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) { - return s.aiRecords(ctx, userID, "smart_table", queryText, cursor, limit) + where := `r.tenant_id=? AND r.user_id=? AND r.record_type='smart_table' AND r.deleted_at IS NULL` + args := []any{s.tenant(), formatInt64(userID)} + if queryText != "" { + where += ` AND (r.title LIKE ? OR r.summary LIKE ? OR a.file_name LIKE ?)` + like := "%" + queryText + "%" + args = append(args, like, like, like) + } + if cursor != "" { + where += ` AND r.record_id limit { + next = items[limit-1].RecordID + items = items[:limit] + } + return items, next, nil } func (s *Store) AIRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) { diff --git a/internal/tableparse/tableparse.go b/internal/tableparse/tableparse.go index b4827a7..edcca6e 100644 --- a/internal/tableparse/tableparse.go +++ b/internal/tableparse/tableparse.go @@ -92,12 +92,17 @@ func parseXLSX(path string) (Profile, error) { } defer f.Close() for _, sheet := range f.GetSheetList() { - rows, err := f.GetRows(sheet) + iter, err := f.Rows(sheet) if err != nil { continue } - cleaned := make([][]string, 0, min(len(rows), maxRows)) - for _, row := range rows { + cleaned := make([][]string, 0, maxRows) + for iter.Next() { + row, err := iter.Columns() + if err != nil { + _ = iter.Close() + return Profile{}, err + } if emptyRow(row) { continue } @@ -106,6 +111,9 @@ func parseXLSX(path string) (Profile, error) { break } } + if err := iter.Close(); err != nil { + return Profile{}, err + } if len(cleaned) > 0 { return profileRows(cleaned), nil }