feat: add workbench AI integration

This commit is contained in:
richjohnrambo
2026-09-28 11:28:24 +08:00
parent 5bf68727fa
commit 8773991015
19 changed files with 6971 additions and 10 deletions
+197
View File
@@ -0,0 +1,197 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
)
var (
ErrNotConfigured = errors.New("ai provider is not configured")
ErrProvider = errors.New("ai provider failed")
ErrTimeout = errors.New("ai provider timeout")
ErrBadOutput = errors.New("ai provider returned invalid output")
)
type Client struct {
cfg config.AIConfig
httpClient *http.Client
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type GenerateRequest struct {
Feature string
Model string
Messages []Message
Temperature float64
MaxTokens int
}
type GenerateResponse struct {
ProviderRequestID string
Model string
Content string
PromptTokens int
CompletionTokens int
TotalTokens int
FinishReason string
LatencyMS int64
}
func NewClient(cfg config.AIConfig) *Client {
timeout := time.Duration(cfg.DeepSeek.TimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 60 * time.Second
}
return &Client{cfg: cfg, httpClient: &http.Client{Timeout: timeout}}
}
func (c *Client) Configured() bool {
return c != nil && strings.TrimSpace(c.cfg.DeepSeek.APIKey) != ""
}
func (c *Client) DefaultModel() string {
if c == nil || strings.TrimSpace(c.cfg.DefaultModel) == "" {
return "deepseek-flash"
}
return strings.TrimSpace(c.cfg.DefaultModel)
}
func (c *Client) ComplexModel() string {
if c == nil || strings.TrimSpace(c.cfg.ComplexModel) == "" {
return c.DefaultModel()
}
return strings.TrimSpace(c.cfg.ComplexModel)
}
func (c *Client) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) {
if c == nil || strings.TrimSpace(c.cfg.DeepSeek.APIKey) == "" {
return GenerateResponse{}, ErrNotConfigured
}
model := strings.TrimSpace(req.Model)
if model == "" {
model = c.DefaultModel()
}
temperature := req.Temperature
if temperature == 0 {
temperature = c.cfg.Temperature
}
if temperature == 0 {
temperature = 0.2
}
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = c.cfg.MaxOutputTokens
}
if maxTokens <= 0 {
maxTokens = 4096
}
body := map[string]any{
"model": model,
"messages": req.Messages,
"temperature": temperature,
"max_tokens": maxTokens,
"stream": false,
}
payload, err := json.Marshal(body)
if err != nil {
return GenerateResponse{}, err
}
baseURL := strings.TrimRight(firstNonEmpty(c.cfg.DeepSeek.BaseURL, "https://api.deepseek.com"), "/")
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return GenerateResponse{}, err
}
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.cfg.DeepSeek.APIKey))
httpReq.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.httpClient.Do(httpReq)
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return GenerateResponse{}, ErrTimeout
}
return GenerateResponse{}, fmt.Errorf("%w: %v", ErrProvider, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout {
return GenerateResponse{}, ErrTimeout
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return GenerateResponse{}, fmt.Errorf("%w: deepseek status %d: %s", ErrProvider, resp.StatusCode, string(raw))
}
var parsed chatCompletionResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return GenerateResponse{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
}
if len(parsed.Choices) == 0 {
return GenerateResponse{}, ErrBadOutput
}
content := parsed.Choices[0].Message.Content
if strings.TrimSpace(content) == "" {
return GenerateResponse{}, ErrBadOutput
}
return GenerateResponse{
ProviderRequestID: parsed.ID,
Model: firstNonEmpty(parsed.Model, model),
Content: content,
PromptTokens: parsed.Usage.PromptTokens,
CompletionTokens: parsed.Usage.CompletionTokens,
TotalTokens: parsed.Usage.TotalTokens,
FinishReason: parsed.Choices[0].FinishReason,
LatencyMS: time.Since(start).Milliseconds(),
}, nil
}
func ExtractJSONObject(raw string) string {
text := strings.TrimSpace(raw)
if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
}
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start >= 0 && end >= start {
return text[start : end+1]
}
return text
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
type chatCompletionResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
FinishReason string `json:"finish_reason"`
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
+387
View File
@@ -0,0 +1,387 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"strings"
)
type Service struct {
client *Client
}
type Usage struct {
ProviderRequestID string
Model string
PromptTokens int
CompletionTokens int
TotalTokens int
LatencyMS int64
}
type PolishResult struct {
Text string
Usage Usage
}
type DocumentResult struct {
Title string
ContentMarkdown string
ContentPlain string
Sections []DocumentSection
Summary string
Usage Usage
}
type DocumentSection struct {
Heading string `json:"heading"`
Content string `json:"content"`
}
type TableAnalysisInput struct {
Topic string `json:"topic"`
FileID string `json:"file_id"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
FileSize int64 `json:"file_size"`
Profile TableProfile `json:"profile"`
}
type TableAnalysisResult struct {
Title string
Metrics []TableMetric
Preview TablePreview
Alerts []TableAlert
Interpretation string
Summary string
Usage Usage
}
type TableMetric struct {
Name string `json:"name"`
Value string `json:"value"`
ChangeText string `json:"change_text"`
Trend string `json:"trend"`
}
type TablePreview struct {
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
TotalRows int `json:"total_rows"`
}
type TableAlert struct {
Level string `json:"level"`
Title string `json:"title"`
Description string `json:"description"`
}
type TableProfile struct {
Columns any `json:"columns,omitempty"`
SampleRows any `json:"sample_rows,omitempty"`
RowCount int `json:"row_count,omitempty"`
Stats any `json:"stats,omitempty"`
Outliers any `json:"outliers,omitempty"`
Parsed bool `json:"parsed"`
Message string `json:"message,omitempty"`
}
type MeetingSummaryInput struct {
MeetingTitle string
StartAt string
EndAt string
Participants []string
Files []string
}
type MeetingSummaryResult struct {
Conclusions []string
Decisions []string
ActionItems []MeetingActionItem
Usage Usage
}
type MeetingActionItem struct {
OwnerName string `json:"owner_name"`
Content string `json:"content"`
DueText string `json:"due_text"`
Status string `json:"status"`
}
func NewService(client *Client) *Service {
return &Service{client: client}
}
func (s *Service) Configured() bool {
return s != nil && s.client != nil && s.client.Configured()
}
func (s *Service) Polish(ctx context.Context, sourceText, scene, tone string, maxOutputChars int) (PolishResult, error) {
if s == nil || s.client == nil {
return PolishResult{}, ErrNotConfigured
}
if maxOutputChars <= 0 {
maxOutputChars = 300
}
system := "你是 IM 工作台的企业办公文案助手。请只输出 JSON,不要输出 Markdown,不要输出额外解释。"
user := fmt.Sprintf(`请润色下面的中文工作文案。
要求:
1. 语气:%s。
2. 场景:%s。
3. 输出不超过 %d 个中文字符。
4. 保持事实不变,不编造数据、人名、时间和结论。
5. 只输出 JSON:{"polished_text":"..."}。
原文:
%s`, firstNonEmpty(tone, "professional"), firstNonEmpty(scene, "general"), maxOutputChars, sourceText)
resp, err := s.client.Generate(ctx, GenerateRequest{
Feature: "text_polish",
Model: s.client.DefaultModel(),
Messages: []Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
MaxTokens: maxOutputChars * 3,
})
if err != nil {
return PolishResult{}, err
}
var out struct {
PolishedText string `json:"polished_text"`
}
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
return PolishResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
}
out.PolishedText = strings.TrimSpace(out.PolishedText)
if out.PolishedText == "" {
return PolishResult{}, ErrBadOutput
}
return PolishResult{Text: out.PolishedText, Usage: usageFromResponse(resp)}, nil
}
func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, documentType, language string) (DocumentResult, error) {
if s == nil || s.client == nil {
return DocumentResult{}, ErrNotConfigured
}
system := "你是 IM 工作台的企业办公文档助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
user := fmt.Sprintf(`请根据用户输入生成一份结构化办公文档。
要求:
1. 输出语言:%s。
2. 文档类型:%s。
3. 标题提示:%s。
4. 内容要忠于原文,不编造事实。
5. sections 建议 3 到 6 个,每个 section 包含 heading 和 content。
6. content_markdown 要适合直接展示,使用二级标题和列表。
7. 只输出 JSON,格式:
{
"title": "string",
"summary": "string",
"content_markdown": "string",
"content_plain": "string",
"sections": [{"heading":"string","content":"string"}]
}
用户输入:
%s`, firstNonEmpty(language, "zh-CN"), firstNonEmpty(documentType, "general"), strings.TrimSpace(titleHint), sourceText)
resp, err := s.client.Generate(ctx, GenerateRequest{
Feature: "smart_document",
Model: s.client.DefaultModel(),
Messages: []Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
MaxTokens: 2400,
})
if err != nil {
return DocumentResult{}, err
}
var out struct {
Title string `json:"title"`
Summary string `json:"summary"`
ContentMarkdown string `json:"content_markdown"`
ContentPlain string `json:"content_plain"`
Sections []DocumentSection `json:"sections"`
}
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
return DocumentResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
}
out.Title = strings.TrimSpace(firstNonEmpty(out.Title, titleHint, "智能文档"))
out.ContentMarkdown = strings.TrimSpace(out.ContentMarkdown)
if out.ContentMarkdown == "" && len(out.Sections) > 0 {
out.ContentMarkdown = markdownFromSections(out.Sections)
}
if strings.TrimSpace(out.ContentPlain) == "" {
out.ContentPlain = plainFromSections(out.Sections)
}
if out.ContentMarkdown == "" {
return DocumentResult{}, ErrBadOutput
}
return DocumentResult{
Title: out.Title,
ContentMarkdown: out.ContentMarkdown,
ContentPlain: strings.TrimSpace(out.ContentPlain),
Sections: out.Sections,
Summary: strings.TrimSpace(out.Summary),
Usage: usageFromResponse(resp),
}, nil
}
func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (TableAnalysisResult, error) {
if s == nil || s.client == nil {
return TableAnalysisResult{}, ErrNotConfigured
}
system := "你是 IM 工作台的数据分析助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
profileJSON, _ := json.Marshal(input.Profile)
user := fmt.Sprintf(`请根据报表信息生成一份简洁的数据解读报告。
注意:
1. 若 profile.parsed=true,请优先依据 columns、sample_rows、stats、outliers 做分析。
2. 若 profile.parsed=false,说明当前没有可用表格内容,只能基于文件元信息给出分析方向,不要编造具体真实数值。
报表主题:%s
文件名:%s
文件类型:%s
文件大小:%d bytes
表格画像:%s
只输出 JSON:
{
"title": "string",
"summary": "string",
"metrics": [{"name":"string","value":"string","change_text":"string","trend":"up|down|flat"}],
"preview": {"columns":["string"],"rows":[["string"]],"total_rows":0},
"alerts": [{"level":"info|warning|danger","title":"string","description":"string"}],
"interpretation": "string"
}`, input.Topic, input.FileName, input.FileType, input.FileSize, string(profileJSON))
resp, err := s.client.Generate(ctx, GenerateRequest{
Feature: "smart_table",
Model: s.client.ComplexModel(),
Messages: []Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
MaxTokens: 1800,
})
if err != nil {
return TableAnalysisResult{}, err
}
var out struct {
Title string `json:"title"`
Summary string `json:"summary"`
Metrics []TableMetric `json:"metrics"`
Preview TablePreview `json:"preview"`
Alerts []TableAlert `json:"alerts"`
Interpretation string `json:"interpretation"`
}
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
return TableAnalysisResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
}
out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告")
if strings.TrimSpace(out.Interpretation) == "" {
return TableAnalysisResult{}, ErrBadOutput
}
return TableAnalysisResult{
Title: out.Title, Metrics: out.Metrics, Preview: out.Preview, Alerts: out.Alerts,
Interpretation: strings.TrimSpace(out.Interpretation), Summary: strings.TrimSpace(out.Summary), Usage: usageFromResponse(resp),
}, nil
}
func (s *Service) SummarizeMeeting(ctx context.Context, input MeetingSummaryInput) (MeetingSummaryResult, error) {
if s == nil || s.client == nil {
return MeetingSummaryResult{}, ErrNotConfigured
}
system := "你是 IM 工作台的会议纪要助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
payload, _ := json.Marshal(input)
user := fmt.Sprintf(`请根据会议上下文生成会议 AI 总结。
要求:
1. 不编造会议中没有出现的事实。
2. 若附件内容不足,请基于标题和上下文给出谨慎总结。
3. action_items 的 status 固定为 pending。
4. 只输出 JSON:
{
"conclusions": ["string"],
"decisions": ["string"],
"action_items": [{"owner_name":"string","content":"string","due_text":"string","status":"pending"}]
}
会议上下文:
%s`, string(payload))
resp, err := s.client.Generate(ctx, GenerateRequest{
Feature: "meeting_summary",
Model: s.client.ComplexModel(),
Messages: []Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
MaxTokens: 1800,
})
if err != nil {
return MeetingSummaryResult{}, err
}
var out struct {
Conclusions []string `json:"conclusions"`
Decisions []string `json:"decisions"`
ActionItems []MeetingActionItem `json:"action_items"`
}
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
return MeetingSummaryResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
}
if len(out.Conclusions) == 0 && len(out.Decisions) == 0 && len(out.ActionItems) == 0 {
return MeetingSummaryResult{}, ErrBadOutput
}
for i := range out.ActionItems {
if strings.TrimSpace(out.ActionItems[i].Status) == "" {
out.ActionItems[i].Status = "pending"
}
}
return MeetingSummaryResult{Conclusions: out.Conclusions, Decisions: out.Decisions, ActionItems: out.ActionItems, Usage: usageFromResponse(resp)}, nil
}
func usageFromResponse(resp GenerateResponse) Usage {
return Usage{
ProviderRequestID: resp.ProviderRequestID,
Model: resp.Model,
PromptTokens: resp.PromptTokens,
CompletionTokens: resp.CompletionTokens,
TotalTokens: resp.TotalTokens,
LatencyMS: resp.LatencyMS,
}
}
func markdownFromSections(sections []DocumentSection) string {
var b strings.Builder
for _, section := range sections {
heading := strings.TrimSpace(section.Heading)
content := strings.TrimSpace(section.Content)
if heading == "" && content == "" {
continue
}
if heading != "" {
b.WriteString("## ")
b.WriteString(heading)
b.WriteString("\n")
}
if content != "" {
b.WriteString(content)
b.WriteString("\n\n")
}
}
return strings.TrimSpace(b.String())
}
func plainFromSections(sections []DocumentSection) string {
parts := make([]string, 0, len(sections)*2)
for _, section := range sections {
if heading := strings.TrimSpace(section.Heading); heading != "" {
parts = append(parts, heading)
}
if content := strings.TrimSpace(section.Content); content != "" {
parts = append(parts, content)
}
}
return strings.Join(parts, "\n")
}