feat: add workbench AI integration
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user