fix: improve ai table file handling

This commit is contained in:
richjohnrambo
2026-09-30 10:02:00 +08:00
parent 1bcce3cdde
commit 47a10c93be
5 changed files with 334 additions and 15 deletions
+171 -9
View File
@@ -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 服务衔接
+26 -2
View File
@@ -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"}
}
+95
View File
@@ -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",
+31 -1
View File
@@ -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<?`
args = append(args, cursor)
}
args = append(args, limit+1)
var items []AIRecord
err := s.db.SelectContext(ctx, &items, `SELECT r.record_id,r.tenant_code,r.user_id,r.record_type,r.title,r.summary,r.status,r.detail_id,r.task_id,
COALESCE(a.file_name, '') AS file_name,COALESCE(a.file_type, '') AS file_type,COALESCE(a.file_size, 0) AS file_size,
r.created_at,r.updated_at,r.deleted_at
FROM wb_ai_record r
LEFT JOIN wb_ai_table_analysis a ON a.tenant_id=r.tenant_id AND a.record_id=r.detail_id
WHERE `+where+` ORDER BY r.created_at DESC,r.record_id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
next := ""
if len(items) > 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) {
+11 -3
View File
@@ -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
}