refactor: 后端仓库层收口(A1:课程/产品/素材)+ 收进工作区既有对象化重构

本提交含两部分。第一部分是本轮工作;第二部分是此前一直留在工作区、
从未提交的对象化重构,与第一部分在文件上互相咬合(internal/repository
整个包都是未跟踪状态,且 api 层已有文件引用它),无法拆成两个可编译的提交。

一、仓库层收口 A1 批(本轮工作)

把 api 层手写的 store.DB 查询收进具名仓库方法,只给真正获益的对象做方法,
不机械包裹全量。本批迁移 22 处裸查询(courses.go 9 / media.go 12 / products.go 1),
新增方法:

- MediaFileRepo.ListByBind / ListForAudit / MarkExtracted
- KnowledgeChunkRepo.CountByMediaFile
- ProductRepo.GetVisibleByID

两条业务口径改由仓库单点持有,避免各处手写漂移:
「只有 approved 素材出现在课程详情」与「已停用产品不在课程详情露出」。

修掉两个真实缺陷:
- ProductRepo.GetByID 缺 Where 条件。此前 GET /api/products/{id} 对任意 id 都返回
  第一条产品、对不存在的 id 返回 200,且 PUT /api/products/{id} 会覆盖第一条产品
  —— 数据损坏级。全仓扫描确认这是唯一一处同型写法。
- ProductRepo.Delete 写 status="deleted",而 DELETE 处理器文档与回包都声称
  "inactive",接口在说谎;管理员用 status=all 拉列表会看到前端不认识的状态。
  已对齐为 inactive(与 CourseRepo.Delete 一致)。

删除 8 个零调用且列名不存在的死方法(一调即 SQL 报错):
- media_file 上的 file_path / file_type / approval_status 三列并不存在,
  GetByPath / ListByType / UpdateStatus 全废
- knowledge_chunk 上的 space_id 列不存在(模型早已改为 knowledge_space_key),
  List / Total / ListBySpaceIDs / DeleteBySpace / SearchByVector 全废
取舍边界:能对当前 schema 跑通的死方法保留,跑不通的删或修。

CourseRepo.List 补齐 status=all 档(此前传给它会当作 status='all' 过滤出空列表)。
该方法此前零调用,现与产品列表语义对齐。

验证:go build ./... 与 go test ./... 全绿;另用真实 HTTP 请求验证 34 项
(课程 17 / 产品 3 / 素材 14),跑在数据库副本与独立 KB_DATA_DIR 上,
含 multipart 真上传 → 审批 → pdftotext 提取 → 分片入库的完整链路。

二、此前未提交的对象化重构(非本轮工作)

- 新增 internal/repository 仓库层、connectors、skills、specialists、xapps、jsonutil,
  model/task_record|task_run|task_artifact、api/task_runtime|action_definition|chat_message
- 删除 api/app_definition、connectors、my_app_center、notification、office_skill、
  export_docx|pptx|xlsx、official_account_* 等,随 XApp/Skill/Specialist/Connector
  可插拔打包方向(AR10/AR11)调整
- 资产目录归位:backend-go/knowledge_source → assets/knowledge/source、
  training_materials → assets/training/materials;README 内相对路径同步加深两级;
  deploy env 补 ASSET_ROOT_DIR 并改 KNOWLEDGE_SOURCE_DIR / TRAINING_MATERIALS_DIR
- 前端新增 skills/ specialists/ connectors/ xapps/ 目录与对应页面

验证:前端 npm run build 通过(7.26s)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-19 01:23:51 +08:00
co-authored by Claude Code
parent ddd2d2cbd8
commit 14f303459e
448 changed files with 18792 additions and 17027 deletions
@@ -0,0 +1,122 @@
package skillapi
import (
"strconv"
"github.com/gin-gonic/gin"
skillmodel "eai_agentplatform/backend/internal/skills/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
func CreateSkillDefinition(c *gin.Context) {
var req definitionReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateDefinitionReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
item := skillmodel.SkillDefinition{
Key: req.Key,
Label: req.Label,
Description: req.Description,
ObjectKind: req.ObjectKind,
Source: req.Source,
ObjectEntryRoute: req.ObjectEntryRoute,
ExposedToUser: req.ExposedToUser,
StarterPromptsJSON: req.StarterPromptsJSON,
PromptTemplate: req.PromptTemplate,
InputSchemaJSON: req.InputSchemaJSON,
OutputSchemaJSON: req.OutputSchemaJSON,
ArtifactSchemaJSON: req.ArtifactSchemaJSON,
ActionRefsJSON: req.ActionRefsJSON,
PolicyRefsJSON: req.PolicyRefsJSON,
OntologyBindingJSON: req.OntologyBindingJSON,
State: req.State,
SortOrder: req.SortOrder,
}
if err := store.DB.Create(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建技能定义失败"))
return
}
web.OK(c, item)
}
func UpdateSkillDefinition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item skillmodel.SkillDefinition
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
return
}
var req definitionReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateDefinitionReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
item.Key = req.Key
item.Label = req.Label
item.Description = req.Description
item.ObjectKind = req.ObjectKind
item.Source = req.Source
item.ObjectEntryRoute = req.ObjectEntryRoute
item.ExposedToUser = req.ExposedToUser
item.StarterPromptsJSON = req.StarterPromptsJSON
item.PromptTemplate = req.PromptTemplate
item.InputSchemaJSON = req.InputSchemaJSON
item.OutputSchemaJSON = req.OutputSchemaJSON
item.ArtifactSchemaJSON = req.ArtifactSchemaJSON
item.ActionRefsJSON = req.ActionRefsJSON
item.PolicyRefsJSON = req.PolicyRefsJSON
item.OntologyBindingJSON = req.OntologyBindingJSON
item.State = req.State
item.SortOrder = req.SortOrder
if err := store.DB.Save(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新技能定义失败"))
return
}
web.OK(c, item)
}
func DeleteSkillDefinition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item skillmodel.SkillDefinition
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
return
}
if err := store.DB.Delete(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除技能定义失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
func parseID(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
web.Fail(c, web.NewBadRequest("无效的 "+name))
return 0, false
}
return uint(id), true
}
@@ -0,0 +1,471 @@
package skillapi
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
skilloffice "eai_agentplatform/backend/internal/skills/runtime/office"
officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
type officeSkillExecuteReq struct {
TaskID uint `json:"task_id"`
SkillKey string `json:"skill_key"`
InputText string `json:"input_text"`
Mode string `json:"mode"`
}
type paddleOCRPayload struct {
Text string `json:"text"`
Lines []map[string]any `json:"lines"`
Engine string `json:"engine"`
Error string `json:"error"`
Raw map[string]any `json:"-"`
}
func ExecuteOfficeSkill(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req officeSkillExecuteReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
req.SkillKey = strings.TrimSpace(req.SkillKey)
req.InputText = strings.TrimSpace(req.InputText)
if req.TaskID == 0 || req.SkillKey == "" || req.InputText == "" {
web.Fail(c, web.NewBadRequest("task_id、skill_key、input_text 为必填"))
return
}
task, ok := loadMyOwnedTask(c, user, req.TaskID)
if !ok {
return
}
definition, ok := skilloffice.BuiltinRegistry().ByKey(req.SkillKey)
if !ok {
web.Fail(c, web.NewNotFoundError("技能不存在"))
return
}
result := skilloffice.BuildOfficeSkillResultMap(definition, req.InputText)
run, createdArtifacts, err := persistOfficeExecution(task, definition, req.InputText, result)
if err != nil {
web.Fail(c, web.NewBadRequest(err.Error()))
return
}
freshTask, _ := reloadTask(task.ID)
web.OK(c, gin.H{
"result": result,
"task": freshTask,
"run": run,
"artifacts": createdArtifacts,
})
}
func ExecuteOfficeSkillOCR(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
taskID, ok := parseUintFromForm(c.PostForm("task_id"))
if !ok || taskID == 0 {
web.Fail(c, web.NewBadRequest("task_id 必填"))
return
}
prompt := strings.TrimSpace(c.PostForm("prompt"))
task, taskOK := loadMyOwnedTask(c, user, taskID)
if !taskOK {
return
}
file, err := c.FormFile("file")
if err != nil {
web.Fail(c, web.NewBadRequest("请上传图片文件"))
return
}
if !looksLikeImage(file.Filename) {
web.Fail(c, web.NewBadRequest("目前仅支持上传图片进行 OCR 识别"))
return
}
src, err := file.Open()
if err != nil {
web.Fail(c, web.NewBadRequest("读取上传文件失败"))
return
}
defer src.Close()
tempFile, err := os.CreateTemp("", "office-ocr-*"+strings.ToLower(filepath.Ext(file.Filename)))
if err != nil {
web.Fail(c, web.NewBadRequest("创建临时文件失败"))
return
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
if _, err := io.Copy(tempFile, src); err != nil {
web.Fail(c, web.NewBadRequest("保存上传文件失败"))
return
}
ocrPayload, err := runPaddleOCR(tempFile.Name())
if err != nil {
web.Fail(c, web.NewBadRequest(err.Error()))
return
}
if strings.TrimSpace(ocrPayload.Text) == "" {
web.Fail(c, web.NewBadRequest("没有识别到可用文字,请换一张更清晰的图片"))
return
}
uploadItem := gin.H{
"title": file.Filename,
"detail": "这张图片已作为 OCR 输入材料保留在任务里。",
"file_format": strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), "."),
"file_size": file.Size,
}
artifactPayload := gin.H{
"engine": firstNonEmpty(ocrPayload.Engine, "paddleocr"),
"prompt": prompt,
"text": ocrPayload.Text,
"lines": ocrPayload.Lines,
"upload_item": uploadItem,
}
artifactPayloadJSON, _ := json.Marshal(artifactPayload)
sourceRefsJSON, _ := json.Marshal([]gin.H{
{
"type": "upload",
"title": file.Filename,
"size": file.Size,
},
})
now := time.Now()
inputJSON, _ := json.Marshal(gin.H{
"prompt": prompt,
"file_name": file.Filename,
"file_size": file.Size,
})
outputJSON, _ := json.Marshal(gin.H{
"summary": fmt.Sprintf("已识别图片《%s》中的文本,并整理为可复制的 OCR 结果。", file.Filename),
"text": ocrPayload.Text,
"lines": ocrPayload.Lines,
})
logsJSON, _ := json.Marshal([]string{
fmt.Sprintf("接收上传文件:%s", file.Filename),
fmt.Sprintf("OCR 引擎:%s", firstNonEmpty(ocrPayload.Engine, "paddleocr")),
"已生成 OCR 识别结果",
})
var run model.TaskRun
var artifact model.TaskArtifact
err = store.DB.Transaction(func(tx *gorm.DB) error {
run = model.TaskRun{
TaskID: task.ID,
SpecialistKey: task.SpecialistKey,
ActionKey: "ocr-understanding",
ActionTitle: "OCR / 图文理解",
ActionType: "ocr",
Status: "done",
InputJSON: string(inputJSON),
OutputJSON: string(outputJSON),
LogsJSON: string(logsJSON),
StartedAt: now,
FinishedAt: &now,
}
if err := tx.Create(&run).Error; err != nil {
return err
}
artifact = model.TaskArtifact{
TaskID: task.ID,
SpecialistKey: task.SpecialistKey,
Title: fmt.Sprintf("%s - OCR 识别结果", task.Title),
ArtifactType: "ocr_text",
Status: specialistruntime.ArtifactStatusDraft,
ContentText: ocrPayload.Text,
ContentJSON: string(artifactPayloadJSON),
SourceRefsJSON: string(sourceRefsJSON),
CreatedByRunID: &run.ID,
}
if err := tx.Create(&artifact).Error; err != nil {
return err
}
contextJSON := specialistruntime.ParseTaskContextJSON(task.ContextJSON)
contextJSON["uploaded_items"] = appendUploadItem(contextJSON["uploaded_items"], uploadItem)
task.ContextJSON = mustMarshalJSON(contextJSON)
task.CurrentRunID = &run.ID
task.CurrentResult = fmt.Sprintf("已识别图片《%s》中的文本。", file.Filename)
task.Status = specialistruntime.TaskStatusDraft
task.LastTriggeredAt = &now
return tx.Save(&task).Error
})
if err != nil {
web.Fail(c, web.NewBadRequest("保存 OCR 结果失败"))
return
}
freshTask, _ := reloadTask(task.ID)
web.OK(c, gin.H{
"text": ocrPayload.Text,
"lines": ocrPayload.Lines,
"engine": firstNonEmpty(ocrPayload.Engine, "paddleocr"),
"artifact": artifact,
"task": freshTask,
"upload_item": uploadItem,
})
}
func persistOfficeExecution(task model.TaskRecord, definition officecontracts.Runtime, inputText string, result gin.H) (model.TaskRun, []model.TaskArtifact, error) {
now := time.Now()
inputJSON, _ := json.Marshal(gin.H{
"skill_key": definition.Key,
"input_text": inputText,
})
outputJSON, _ := json.Marshal(result)
logsJSON, _ := json.Marshal([]string{
fmt.Sprintf("已执行技能:%s", definition.Label),
"已生成结果并写入任务产物",
})
run := model.TaskRun{}
artifacts := make([]model.TaskArtifact, 0)
err := store.DB.Transaction(func(tx *gorm.DB) error {
run = model.TaskRun{
TaskID: task.ID,
SpecialistKey: task.SpecialistKey,
ActionKey: definition.Key,
ActionTitle: definition.Label,
ActionType: definition.ActionType,
Status: "done",
InputJSON: string(inputJSON),
OutputJSON: string(outputJSON),
LogsJSON: string(logsJSON),
StartedAt: now,
FinishedAt: &now,
}
if err := tx.Create(&run).Error; err != nil {
return err
}
for _, item := range toArtifactMaps(result["artifacts"]) {
contentJSON, _ := json.Marshal(item)
contentText := firstNonEmpty(asString(item["content"]), asString(result["summary"]))
artifact := model.TaskArtifact{
TaskID: task.ID,
SpecialistKey: task.SpecialistKey,
Title: firstNonEmpty(asString(item["title"]), definition.ResultTitle),
ArtifactType: firstNonEmpty(asString(item["kind"]), definition.ActionType, "text"),
Status: specialistruntime.ArtifactStatusDraft,
ContentText: contentText,
ContentJSON: string(contentJSON),
SourceRefsJSON: "[]",
CreatedByRunID: &run.ID,
}
if err := tx.Create(&artifact).Error; err != nil {
return err
}
artifacts = append(artifacts, artifact)
}
task.CurrentRunID = &run.ID
task.CurrentResult = firstNonEmpty(asString(result["summary"]), definition.Summary)
task.Status = specialistruntime.TaskStatusDraft
task.LastTriggeredAt = &now
return tx.Save(&task).Error
})
return run, artifacts, err
}
func loadMyOwnedTask(c *gin.Context, user *model.User, taskID uint) (model.TaskRecord, bool) {
var task model.TaskRecord
if err := specialistruntime.MyTaskQuery(user).Where("id = ?", taskID).First(&task).Error; err != nil {
web.Fail(c, web.NewNotFoundError("任务不存在"))
return task, false
}
return task, true
}
func reloadTask(taskID uint) (model.TaskRecord, error) {
var task model.TaskRecord
err := store.DB.First(&task, taskID).Error
return task, err
}
func anyToStrings(value any) []string {
switch items := value.(type) {
case []string:
return items
case []any:
out := make([]string, 0, len(items))
for _, item := range items {
text := asString(item)
if text != "" {
out = append(out, text)
}
}
return out
default:
return nil
}
}
func asString(value any) string {
if value == nil {
return ""
}
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
text := strings.TrimSpace(fmt.Sprint(value))
if text == "<nil>" {
return ""
}
return text
}
}
func toArtifactMaps(value any) []gin.H {
items, ok := value.([]gin.H)
if ok {
return items
}
raw, ok := value.([]any)
if !ok {
return nil
}
out := make([]gin.H, 0, len(raw))
for _, item := range raw {
if mapped, ok := item.(map[string]any); ok {
out = append(out, gin.H(mapped))
}
}
return out
}
func appendUploadItem(value any, item gin.H) []any {
list, ok := value.([]any)
if !ok {
list = []any{}
}
return append(list, item)
}
func mustMarshalJSON(value any) string {
data, _ := json.Marshal(value)
return string(data)
}
func parseUintFromForm(value string) (uint, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
var parsed uint64
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil {
return 0, false
}
return uint(parsed), true
}
func looksLikeImage(name string) bool {
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(name)))
switch ext {
case ".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff":
return true
default:
return false
}
}
func runPaddleOCR(imagePath string) (*paddleOCRPayload, error) {
scriptPath, err := resolvePaddleOCRScriptPath()
if err != nil {
return nil, fmt.Errorf("未找到 PaddleOCR 脚本:%w", err)
}
cmd := exec.Command("python3", scriptPath, imagePath)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("PaddleOCR 执行失败:%s", firstNonEmpty(strings.TrimSpace(stderr.String()), err.Error()))
}
payload := &paddleOCRPayload{}
if err := json.Unmarshal(stdout.Bytes(), payload); err != nil {
return nil, fmt.Errorf("PaddleOCR 返回结果解析失败")
}
if payload.Error != "" {
return nil, fmt.Errorf("PaddleOCR 返回错误:%s", payload.Error)
}
return payload, nil
}
func resolvePaddleOCRScriptPath() (string, error) {
candidates := []string{
filepath.Join("scripts", "paddle_ocr_extract.py"),
filepath.Join("backend-go", "scripts", "paddle_ocr_extract.py"),
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", os.ErrNotExist
}
func escapeSVGText(value string) string {
replacer := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return replacer.Replace(value)
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
@@ -0,0 +1,45 @@
package skillapi
import (
"strings"
"github.com/gin-gonic/gin"
skillmodel "eai_agentplatform/backend/internal/skills/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
func ListSkillDefinitions(c *gin.Context) {
q := store.DB.Model(&skillmodel.SkillDefinition{})
if c.Query("state") == "" {
q = q.Where("state = ?", "active")
} else {
q = q.Where("state = ?", c.Query("state"))
}
if c.Query("exposed_to_user") != "" {
q = q.Where("exposed_to_user = ?", c.Query("exposed_to_user") == "true")
}
var items []skillmodel.SkillDefinition
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询技能定义失败"))
return
}
web.OK(c, items)
}
func GetSkillDefinitionByKey(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("技能 key 不能为空"))
return
}
var item skillmodel.SkillDefinition
if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil {
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
return
}
web.OK(c, item)
}
@@ -0,0 +1,175 @@
package skillapi
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/middleware"
reportgeneration "eai_agentplatform/backend/internal/skills/packages/report_generation"
"eai_agentplatform/backend/internal/web"
)
// ReportGenerate POST /api/report/generate —— 调用 LLM 生成报告正文
func ReportGenerate(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req reportgeneration.ReportGenRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" {
web.Fail(c, web.NewBadRequest("topic 必填"))
return
}
// 1. 检索知识库片段
spaceKey := ""
if req.Knowledge != "" {
spaceKey = req.Knowledge
}
knowledgeContent := reportgeneration.RetrieveReportKnowledge(req.Topic, spaceKey)
// 2. 构建 LLM 提示词
var chapters []reportgeneration.ReportChapter
if len(req.Sections) > 0 {
// 使用用户指定的章节
chapters = reportgeneration.PromptReportWithSections(req, knowledgeContent)
} else {
// LLM 自动规划章节
chapters = reportgeneration.PromptReportAutoChapters(req, knowledgeContent)
}
// 3. 组装报告
report := reportgeneration.ReportContent{
Topic: req.Topic,
Summary: strings.TrimSpace(req.Summary),
Chapters: chapters,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
web.OK(c, gin.H{
"report": report,
})
}
// ReportExportPDF POST /api/report/export/pdf —— 导出报告为 PDF 文件
func ReportExportPDF(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Report reportgeneration.ReportContent `json:"report"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" {
web.Fail(c, web.NewBadRequest("report 不能为空"))
return
}
data, err := reportgeneration.ExportReportPDF(req.Report)
if err != nil {
web.Fail(c, web.NewBadRequest("PDF 生成失败: "+err.Error()))
return
}
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition",
"attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pdf\"")
c.Data(200, "application/pdf", data)
}
// ReportPreview GET /api/report/preview —— 预览报告(JSON,用于前端预览)
func ReportPreview(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req reportgeneration.ReportGenRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" {
web.Fail(c, web.NewBadRequest("topic 必填"))
return
}
// 使用 LLM 生成报告内容
report := reportgeneration.PromptReportPreview(req)
web.OK(c, gin.H{
"report": report,
})
}
// ReportExportXLSX POST /api/report/export/xlsx —— 导出报告为 Excel
func ReportExportXLSX(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Report reportgeneration.ReportContent `json:"report"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" {
web.Fail(c, web.NewBadRequest("report 不能为空"))
return
}
data, err := reportgeneration.ExportReportXLSX(req.Report)
if err != nil {
web.Fail(c, web.NewBadRequest("Excel 生成失败: "+err.Error()))
return
}
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".xlsx\"")
c.Data(200, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
}
// ReportExportDOCX POST /api/report/export/docx —— 导出报告为 Word
func ReportExportDOCX(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Report reportgeneration.ReportContent `json:"report"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" {
web.Fail(c, web.NewBadRequest("report 不能为空"))
return
}
data, err := reportgeneration.ExportReportDOCX(req.Report)
if err != nil {
web.Fail(c, web.NewBadRequest("Word 生成失败: "+err.Error()))
return
}
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".docx\"")
c.Data(200, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
}
// ReportExportPPTX POST /api/report/export/pptx —— 导出报告为 PPTX
func ReportExportPPTX(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Report reportgeneration.ReportContent `json:"report"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" {
web.Fail(c, web.NewBadRequest("report 不能为空"))
return
}
data, err := reportgeneration.ExportReportPPTX(req.Report)
if err != nil {
web.Fail(c, web.NewBadRequest("PPTX 生成失败: "+err.Error()))
return
}
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.presentationml.presentation")
c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pptx\"")
c.Data(200, "application/vnd.openxmlformats-officedocument.presentationml.presentation", data)
}
@@ -0,0 +1,21 @@
package skillapi
type definitionReq struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
ObjectKind string `json:"object_kind"`
Source string `json:"source"`
ObjectEntryRoute string `json:"object_entry_route"`
ExposedToUser bool `json:"exposed_to_user"`
StarterPromptsJSON string `json:"starter_prompts_json"`
PromptTemplate string `json:"prompt_template"`
InputSchemaJSON string `json:"input_schema_json"`
OutputSchemaJSON string `json:"output_schema_json"`
ArtifactSchemaJSON string `json:"artifact_schema_json"`
ActionRefsJSON string `json:"action_refs_json"`
PolicyRefsJSON string `json:"policy_refs_json"`
OntologyBindingJSON string `json:"ontology_binding_json"`
State string `json:"state"`
SortOrder int `json:"sort_order"`
}
@@ -0,0 +1,72 @@
package skillapi
import (
"strings"
"eai_agentplatform/backend/internal/jsonutil"
"eai_agentplatform/backend/internal/web"
)
func normalizeDefinitionReq(req *definitionReq) {
req.Key = strings.TrimSpace(req.Key)
req.Label = strings.TrimSpace(req.Label)
req.Description = strings.TrimSpace(req.Description)
req.ObjectKind = strings.TrimSpace(req.ObjectKind)
req.Source = strings.TrimSpace(req.Source)
req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute)
req.StarterPromptsJSON = strings.TrimSpace(req.StarterPromptsJSON)
req.PromptTemplate = strings.TrimSpace(req.PromptTemplate)
req.InputSchemaJSON = strings.TrimSpace(req.InputSchemaJSON)
req.OutputSchemaJSON = strings.TrimSpace(req.OutputSchemaJSON)
req.ArtifactSchemaJSON = strings.TrimSpace(req.ArtifactSchemaJSON)
req.ActionRefsJSON = strings.TrimSpace(req.ActionRefsJSON)
req.PolicyRefsJSON = strings.TrimSpace(req.PolicyRefsJSON)
req.OntologyBindingJSON = strings.TrimSpace(req.OntologyBindingJSON)
req.State = strings.TrimSpace(req.State)
}
func validateDefinitionReq(req *definitionReq) *web.AppError {
normalizeDefinitionReq(req)
if req.Key == "" || req.Label == "" {
return web.NewBadRequest("key、label 为必填")
}
if req.ObjectKind == "" {
req.ObjectKind = "skill"
}
switch req.ObjectKind {
case "specialist", "skill":
default:
return web.NewBadRequest("object_kind 只能是 specialist 或 skill")
}
if req.Source == "" {
req.Source = "eai"
}
if req.State == "" {
req.State = "active"
}
if req.State != "active" && req.State != "inactive" {
return web.NewBadRequest("state 只能是 active 或 inactive")
}
if !jsonutil.ValidateStringArrayJSON(req.StarterPromptsJSON) {
return web.NewBadRequest("starter_prompts_json 必须是字符串数组")
}
if !jsonutil.ValidateObjectJSON(req.InputSchemaJSON) {
return web.NewBadRequest("input_schema_json 必须是 JSON 对象")
}
if !jsonutil.ValidateObjectJSON(req.OutputSchemaJSON) {
return web.NewBadRequest("output_schema_json 必须是 JSON 对象")
}
if !jsonutil.ValidateObjectJSON(req.ArtifactSchemaJSON) {
return web.NewBadRequest("artifact_schema_json 必须是 JSON 对象")
}
if !jsonutil.ValidateStringArrayJSON(req.ActionRefsJSON) {
return web.NewBadRequest("action_refs_json 必须是字符串数组")
}
if !jsonutil.ValidateStringArrayJSON(req.PolicyRefsJSON) {
return web.NewBadRequest("policy_refs_json 必须是字符串数组")
}
if !jsonutil.ValidateObjectJSON(req.OntologyBindingJSON) {
return web.NewBadRequest("ontology_binding_json 必须是 JSON 对象")
}
return nil
}
@@ -0,0 +1,32 @@
package skillcontracts
type SharedReferencePolicy string
const (
SharedReferenceReadonlyHistory SharedReferencePolicy = "readonly_history"
SharedReferenceTombstone SharedReferencePolicy = "convert_to_tombstone"
SharedReferenceDetach SharedReferencePolicy = "detach_reference"
SharedReferenceBlockUninstall SharedReferencePolicy = "block_uninstall"
)
type UninstallMeta struct {
OwnedDefinitionKeys []string
OwnedCatalogEntries []string
ReferencePolicy SharedReferencePolicy
}
type Manifest struct {
Key string
Label string
OwnedDefinitionKeys []string
OwnedCatalogEntries []string
ReferencePolicy SharedReferencePolicy
}
func (m Manifest) UninstallMeta() UninstallMeta {
return UninstallMeta{
OwnedDefinitionKeys: append([]string(nil), m.OwnedDefinitionKeys...),
OwnedCatalogEntries: append([]string(nil), m.OwnedCatalogEntries...),
ReferencePolicy: m.ReferencePolicy,
}
}
@@ -0,0 +1,77 @@
package skillcore
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
var ValidSkillKeys = buildValidSkillKeys()
func buildValidSkillKeys() map[string]bool {
keys := make(map[string]bool)
for _, manifest := range builtinRegistry.Manifests() {
if strings.TrimSpace(manifest.Key) == "" {
continue
}
keys[manifest.Key] = true
}
return keys
}
func SortedValidSkillKeys() []string {
keys := make([]string, 0, len(ValidSkillKeys))
for k := range ValidSkillKeys {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func ParseAllowedSkills(raw string) []string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
var keys []string
if err := json.Unmarshal([]byte(trimmed), &keys); err != nil {
return nil
}
out := make([]string, 0, len(keys))
for _, k := range keys {
if k = strings.TrimSpace(k); k != "" {
out = append(out, k)
}
}
return out
}
func MarshalAllowedSkills(keys []string) (string, error) {
if len(keys) == 0 {
return "", nil
}
raw, err := json.Marshal(keys)
if err != nil {
return "", err
}
return string(raw), nil
}
func ValidateAllowedSkills(keys []string) error {
seen := make(map[string]bool, len(keys))
for _, k := range keys {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if !ValidSkillKeys[k] {
return fmt.Errorf("技能 key %q 非法,可选值:%s", k, strings.Join(SortedValidSkillKeys(), " / "))
}
if seen[k] {
return fmt.Errorf("技能 key %q 重复绑定", k)
}
seen[k] = true
}
return nil
}
@@ -0,0 +1,162 @@
package skillcore
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
const packageDirGlob = "../../../../frontend/src/skills/packages/*/manifest.js"
var manifestKeyRe = regexp.MustCompile(`key:\s*'([^']+)'`)
func TestValidSkillKeysMatchFrontend(t *testing.T) {
frontendKeys := make(map[string]bool)
packageFiles, err := filepath.Glob(filepath.Clean(packageDirGlob))
if err != nil || len(packageFiles) == 0 {
t.Fatalf("未找到前端 skill package manifests: %v", err)
}
for _, path := range packageFiles {
mergeKeysFromFile(t, path, manifestKeyRe, frontendKeys)
}
if len(frontendKeys) == 0 {
t.Fatal("前端 skill package manifest 未解析出任何 key,请同步更新测试正则")
}
var onlyBackend, onlyFrontend []string
for k := range ValidSkillKeys {
if !frontendKeys[k] {
onlyBackend = append(onlyBackend, k)
}
}
for k := range frontendKeys {
if !ValidSkillKeys[k] {
onlyFrontend = append(onlyFrontend, k)
}
}
sort.Strings(onlyBackend)
sort.Strings(onlyFrontend)
if len(onlyBackend) > 0 {
t.Errorf("ValidSkillKeys 里有、前端 skill manifests 里没有的 key:\n %s",
strings.Join(onlyBackend, "\n "))
}
if len(onlyFrontend) > 0 {
t.Errorf("前端 skill manifests 里有、ValidSkillKeys 里没有的 key:\n %s",
strings.Join(onlyFrontend, "\n "))
}
}
func TestParseAllowedSkills(t *testing.T) {
cases := []struct {
name string
raw string
want []string
}{
{"空串返回 nil", "", nil},
{"纯空白返回 nil", " ", nil},
{"非法 JSON 返回 nil(读路径不报错)", `{"not":"array"}`, nil},
{"正常数组", `["contract-review","batch-extract"]`, []string{"contract-review", "batch-extract"}},
{"跳过多余空白", `[" contract-review "]`, []string{"contract-review"}},
{"空数组返回空切片", `[]`, []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ParseAllowedSkills(tc.raw)
if len(got) != len(tc.want) {
t.Fatalf("ParseAllowedSkills(%q) = %v,期望 %v", tc.raw, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("ParseAllowedSkills(%q) = %v,期望 %v", tc.raw, got, tc.want)
}
}
})
}
}
func TestMarshalAllowedSkills(t *testing.T) {
t.Run("空列表存空串而非 []", func(t *testing.T) {
got, err := MarshalAllowedSkills(nil)
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("期望空串,实际 %q", got)
}
})
t.Run("往返一致", func(t *testing.T) {
in := []string{"contract-review", "contract-brief"}
raw, err := MarshalAllowedSkills(in)
if err != nil {
t.Fatal(err)
}
out := ParseAllowedSkills(raw)
if len(out) != len(in) {
t.Fatalf("往返后长度变了: %v -> %v", in, out)
}
for i := range in {
if in[i] != out[i] {
t.Errorf("往返后顺序变了: %v -> %v(顺序即优先级,必须保持)", in, out)
}
}
})
}
func TestValidateAllowedSkills(t *testing.T) {
t.Run("合法列表通过", func(t *testing.T) {
if err := ValidateAllowedSkills([]string{"contract-review", "batch-extract"}); err != nil {
t.Errorf("不应报错: %v", err)
}
})
t.Run("空列表通过", func(t *testing.T) {
if err := ValidateAllowedSkills(nil); err != nil {
t.Errorf("未绑定技能应合法: %v", err)
}
})
t.Run("非法 key 报错", func(t *testing.T) {
err := ValidateAllowedSkills([]string{"contract-review", "typo-skill"})
if err == nil {
t.Fatal("非法 key 应报错")
}
if !strings.Contains(err.Error(), "typo-skill") {
t.Errorf("错误信息应点出非法 key,实际: %v", err)
}
})
t.Run("重复 key 报错", func(t *testing.T) {
err := ValidateAllowedSkills([]string{"contract-review", "contract-review"})
if err == nil {
t.Fatal("重复绑定应报错(首个为主技能的语义会变得不确定)")
}
})
}
func readFrontendFile(t *testing.T, relPath string) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Clean(relPath))
if err != nil {
t.Skipf("跳过:读不到前端 %s(交付形态无前端源码): %v", relPath, err)
}
return raw
}
func mergeKeysFromFile(t *testing.T, relPath string, re *regexp.Regexp, out map[string]bool) {
t.Helper()
raw := readFrontendFile(t, relPath)
matches := re.FindAllStringSubmatch(string(raw), -1)
if len(matches) == 0 {
t.Fatalf("在前端 %s 里没抓到任何技能 key,请同步更新测试正则", relPath)
}
for _, match := range matches {
out[match[1]] = true
}
}
@@ -0,0 +1,55 @@
package skillcore
import (
audiotranscribe "eai_agentplatform/backend/internal/skills/packages/audio_transcribe"
batchextract "eai_agentplatform/backend/internal/skills/packages/batch_extract"
contractbrief "eai_agentplatform/backend/internal/skills/packages/contract_brief"
contractreview "eai_agentplatform/backend/internal/skills/packages/contract_review"
copyproofreading "eai_agentplatform/backend/internal/skills/packages/copy_proofreading"
documenttranslate "eai_agentplatform/backend/internal/skills/packages/document_translate"
emaildrafting "eai_agentplatform/backend/internal/skills/packages/email_drafting"
interviewsummary "eai_agentplatform/backend/internal/skills/packages/interview_summary"
longformwriting "eai_agentplatform/backend/internal/skills/packages/longform_writing"
meetingminutes "eai_agentplatform/backend/internal/skills/packages/meeting_minutes"
mindmap "eai_agentplatform/backend/internal/skills/packages/mind_map"
ocrunderstanding "eai_agentplatform/backend/internal/skills/packages/ocr_understanding"
policyrewrite "eai_agentplatform/backend/internal/skills/packages/policy_rewrite"
pptgeneration "eai_agentplatform/backend/internal/skills/packages/ppt_generation"
progressreport "eai_agentplatform/backend/internal/skills/packages/progress_report"
projectplanning "eai_agentplatform/backend/internal/skills/packages/project_planning"
proposalsummary "eai_agentplatform/backend/internal/skills/packages/proposal_summary"
reportgeneration "eai_agentplatform/backend/internal/skills/packages/report_generation"
smartassistant "eai_agentplatform/backend/internal/skills/packages/smart_assistant"
surveysummary "eai_agentplatform/backend/internal/skills/packages/survey_summary"
tablecleanup "eai_agentplatform/backend/internal/skills/packages/table_cleanup"
texttoolkit "eai_agentplatform/backend/internal/skills/packages/text_toolkit"
)
var builtinRegistry = NewRegistry(
smartassistant.Manifest,
documenttranslate.Manifest,
copyproofreading.Manifest,
audiotranscribe.Manifest,
batchextract.Manifest,
contractreview.Manifest,
reportgeneration.Manifest,
pptgeneration.Manifest,
mindmap.Manifest,
longformwriting.Manifest,
texttoolkit.Manifest,
ocrunderstanding.Manifest,
meetingminutes.Manifest,
projectplanning.Manifest,
emaildrafting.Manifest,
tablecleanup.Manifest,
proposalsummary.Manifest,
progressreport.Manifest,
contractbrief.Manifest,
interviewsummary.Manifest,
policyrewrite.Manifest,
surveysummary.Manifest,
)
func BuiltinRegistry() *Registry {
return builtinRegistry
}
@@ -0,0 +1,38 @@
package skillcore
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
type Registry struct {
manifests []skillcontracts.Manifest
byKey map[string]skillcontracts.Manifest
}
func NewRegistry(manifests ...skillcontracts.Manifest) *Registry {
items := make([]skillcontracts.Manifest, 0, len(manifests))
byKey := make(map[string]skillcontracts.Manifest, len(manifests))
for _, manifest := range manifests {
if manifest.Key == "" {
continue
}
items = append(items, manifest)
byKey[manifest.Key] = manifest
}
return &Registry{manifests: items, byKey: byKey}
}
func (r *Registry) Manifests() []skillcontracts.Manifest {
if r == nil {
return nil
}
items := make([]skillcontracts.Manifest, len(r.manifests))
copy(items, r.manifests)
return items
}
func (r *Registry) ByKey(key string) (skillcontracts.Manifest, bool) {
if r == nil {
return skillcontracts.Manifest{}, false
}
item, ok := r.byKey[key]
return item, ok
}
@@ -0,0 +1,18 @@
package skillcore
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
type UninstallPreview struct {
DefinitionKeys []string
CatalogEntries []string
Policy skillcontracts.SharedReferencePolicy
}
func BuildUninstallPreview(manifest skillcontracts.Manifest) UninstallPreview {
meta := manifest.UninstallMeta()
return UninstallPreview{
DefinitionKeys: meta.OwnedDefinitionKeys,
CatalogEntries: meta.OwnedCatalogEntries,
Policy: meta.ReferencePolicy,
}
}
@@ -0,0 +1,31 @@
package skillmodel
import "time"
// SkillDefinition 对外暴露的任务级能力定义。
type SkillDefinition struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
Label string `gorm:"size:128;not null" json:"label"`
DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"`
EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"`
Description string `gorm:"type:text" json:"description"`
ObjectKind string `gorm:"column:object_kind;size:32;not null;default:skill;index" json:"object_kind"`
Source string `gorm:"size:32;not null;default:eai;index" json:"source"`
ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;default:''" json:"object_entry_route"`
ExposedToUser bool `gorm:"not null;default:true;index" json:"exposed_to_user"`
StarterPromptsJSON string `gorm:"type:text" json:"starter_prompts_json"`
PromptTemplate string `gorm:"type:text" json:"prompt_template"`
InputSchemaJSON string `gorm:"type:text" json:"input_schema_json"`
OutputSchemaJSON string `gorm:"type:text" json:"output_schema_json"`
ArtifactSchemaJSON string `gorm:"type:text" json:"artifact_schema_json"`
ActionRefsJSON string `gorm:"type:text" json:"action_refs_json"`
PolicyRefsJSON string `gorm:"type:text" json:"policy_refs_json"`
OntologyBindingJSON string `gorm:"type:text" json:"ontology_binding_json"`
State string `gorm:"size:16;not null;default:active;index" json:"state"`
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SkillDefinition) TableName() string { return "skill_definition" }
@@ -0,0 +1,11 @@
package audiotranscribe
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "audio-transcribe",
Label: "语音转写",
OwnedDefinitionKeys: []string{"skill.audio-transcribe.definition"},
OwnedCatalogEntries: []string{"catalog.skill.audio-transcribe"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,11 @@
package batchextract
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "batch-extract",
Label: "批量提取",
OwnedDefinitionKeys: []string{"skill.batch-extract.definition"},
OwnedCatalogEntries: []string{"catalog.skill.batch-extract"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,11 @@
package contractbrief
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "contract-brief",
Label: "合同要点提炼",
OwnedDefinitionKeys: []string{"skill.contract-brief.definition"},
OwnedCatalogEntries: []string{"catalog.skill.contract-brief"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package contractbrief
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "contract-brief",
Label: "合同要点提炼",
PlanTitle: "合同要点提炼流程",
PlanSteps: []string{"识别合同结构", "提炼核心条款", "标记风险事项", "输出摘要版本"},
ResultTitle: "合同要点摘要",
Summary: "已提炼出合同核心条款、主要风险和待确认事项,方便快速预读和继续审查。",
Highlights: []string{"适合采购、合作、服务类合同", "自动压缩核心条款与风险点", "默认沉淀摘要、风险和待确认项"},
ActionType: "summary",
}
@@ -0,0 +1,11 @@
package contractreview
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "contract-review",
Label: "合同审查",
OwnedDefinitionKeys: []string{"skill.contract-review.definition"},
OwnedCatalogEntries: []string{"catalog.skill.contract-review"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,11 @@
package copyproofreading
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "copy-proofreading",
Label: "文案校对",
OwnedDefinitionKeys: []string{"skill.copy-proofreading.definition"},
OwnedCatalogEntries: []string{"catalog.skill.copy-proofreading"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,11 @@
package documenttranslate
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "document-translate",
Label: "文档翻译",
OwnedDefinitionKeys: []string{"skill.document-translate.definition"},
OwnedCatalogEntries: []string{"catalog.skill.document-translate"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,11 @@
package emaildrafting
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "email-drafting",
Label: "邮件起草",
OwnedDefinitionKeys: []string{"skill.email-drafting.definition"},
OwnedCatalogEntries: []string{"catalog.skill.email-drafting"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package emaildrafting
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "email-drafting",
Label: "邮件起草",
PlanTitle: "邮件起草流程",
PlanSteps: []string{"识别沟通目的", "整理背景与重点", "生成正式邮件", "补充简短跟进版本"},
ResultTitle: "邮件草稿",
Summary: "已生成一版可直接发送的邮件正文,并补了简短跟进版本和要点摘要。",
Highlights: []string{"适合汇报、催办、确认和通知", "兼顾正式语气与清晰表达", "默认沉淀长邮件和短消息版本"},
ActionType: "communication",
}
@@ -0,0 +1,11 @@
package interviewsummary
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "interview-summary",
Label: "访谈整理",
OwnedDefinitionKeys: []string{"skill.interview-summary.definition"},
OwnedCatalogEntries: []string{"catalog.skill.interview-summary"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package interviewsummary
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "interview-summary",
Label: "访谈整理",
PlanTitle: "访谈整理流程",
PlanSteps: []string{"归并访谈内容", "提炼主题观点", "总结关键洞察", "输出整理版本"},
ResultTitle: "访谈整理稿",
Summary: "已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。",
Highlights: []string{"适合用户访谈、需求调研、深度沟通", "自动提炼主题与洞察", "默认沉淀摘要、洞察和行动建议"},
ActionType: "document",
}
@@ -0,0 +1,11 @@
package longformwriting
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "longform-writing",
Label: "长文创作",
OwnedDefinitionKeys: []string{"skill.longform-writing.definition"},
OwnedCatalogEntries: []string{"catalog.skill.longform-writing"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package longformwriting
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "longform-writing",
Label: "长文创作",
PlanTitle: "长文创作流程",
PlanSteps: []string{"明确文体与受众", "规划章节大纲", "生成正文骨架", "整理摘要与交付稿"},
ResultTitle: "长文草稿",
Summary: "已输出一版长文大纲和正文骨架,方便继续扩写为正式稿。",
Highlights: []string{"先给大纲再给正文草稿", "适合方案、制度和长文章", "结果可下载继续改写"},
ActionType: "document",
}
@@ -0,0 +1,11 @@
package meetingminutes
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "meeting-minutes",
Label: "会议纪要",
OwnedDefinitionKeys: []string{"skill.meeting-minutes.definition"},
OwnedCatalogEntries: []string{"catalog.skill.meeting-minutes"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package meetingminutes
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "meeting-minutes",
Label: "会议纪要",
PlanTitle: "会议纪要整理流程",
PlanSteps: []string{"识别会议主题", "提炼关键结论", "整理行动项", "输出同步版本"},
ResultTitle: "会议纪要草稿",
Summary: "已按会议纪要结构整理出主题、结论和待办,方便直接同步给参会人。",
Highlights: []string{"自动提炼结论与待办", "适合例会、评审会和复盘会", "默认沉淀纪要正文和行动项清单"},
ActionType: "document",
}
@@ -0,0 +1,11 @@
package mindmap
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "mind-map",
Label: "思维导图",
OwnedDefinitionKeys: []string{"skill.mind-map.definition"},
OwnedCatalogEntries: []string{"catalog.skill.mind-map"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package mindmap
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "mind-map",
Label: "思维导图",
PlanTitle: "思维导图生成流程",
PlanSteps: []string{"识别主题主线", "拆分一级分支", "补齐关键节点", "整理导图源结构"},
ResultTitle: "导图结构草案",
Summary: "已把输入内容压成导图结构,并生成可继续分享和编辑的导图结果。",
Highlights: []string{"可下载导图 SVG", "保留导图文字结构", "适合任务拆解与知识梳理"},
ActionType: "mindmap",
}
@@ -0,0 +1,11 @@
package ocrunderstanding
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "ocr-understanding",
Label: "OCR / 图文理解",
OwnedDefinitionKeys: []string{"skill.ocr-understanding.definition"},
OwnedCatalogEntries: []string{"catalog.skill.ocr-understanding"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package ocrunderstanding
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "ocr-understanding",
Label: "OCR / 图文理解",
PlanTitle: "图文理解流程",
PlanSteps: []string{"识别图像内容", "抽取文字与字段", "整理结构摘要", "输出可复用结果"},
ResultTitle: "识别结果摘要",
Summary: "上传图片后,会优先使用 PaddleOCR 识别文字,并把结果写入当前任务产物。",
Highlights: []string{"走后端正式 OCR 链路", "识别结果会写入任务产物", "上传图片会长期保留到任务输入"},
ActionType: "ocr",
}
@@ -0,0 +1,11 @@
package policyrewrite
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "policy-rewrite",
Label: "制度改写",
OwnedDefinitionKeys: []string{"skill.policy-rewrite.definition"},
OwnedCatalogEntries: []string{"catalog.skill.policy-rewrite"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package policyrewrite
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "policy-rewrite",
Label: "制度改写",
PlanTitle: "制度改写流程",
PlanSteps: []string{"识别制度目标", "统一结构口径", "重写正式正文", "补充修订说明"},
ResultTitle: "制度改写稿",
Summary: "已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。",
Highlights: []string{"适合制度、流程、规范文档", "兼顾正式表达与清晰可读", "默认沉淀改写正文和修订说明"},
ActionType: "document",
}
@@ -0,0 +1,11 @@
package pptgeneration
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "ppt-generation",
Label: "PPT 生成",
OwnedDefinitionKeys: []string{"skill.ppt-generation.definition"},
OwnedCatalogEntries: []string{"catalog.skill.ppt-generation"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package pptgeneration
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "ppt-generation",
Label: "PPT 生成",
PlanTitle: "PPT 生成流程",
PlanSteps: []string{"确认汇报目标", "规划页面结构", "提炼每页要点", "整理讲稿备注"},
ResultTitle: "演示文稿骨架",
Summary: "已按输入内容整理出演示逻辑,并生成可继续下载和细化的 PPT 结果。",
Highlights: []string{"支持直接下载 .pptx", "同时保留大纲与讲稿备注", "适合汇报、方案和复盘"},
ActionType: "presentation",
}
@@ -0,0 +1,11 @@
package progressreport
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "progress-report",
Label: "周报 / 日报",
OwnedDefinitionKeys: []string{"skill.progress-report.definition"},
OwnedCatalogEntries: []string{"catalog.skill.progress-report"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package progressreport
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "progress-report",
Label: "周报 / 日报",
PlanTitle: "进展汇报流程",
PlanSteps: []string{"归并本期工作", "提炼关键进展", "整理风险事项", "输出汇报版本"},
ResultTitle: "进展汇报草稿",
Summary: "已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。",
Highlights: []string{"适合日报、周报和阶段同步", "自动补齐风险与下步计划", "默认沉淀正式汇报版和简短摘要版"},
ActionType: "report",
}
@@ -0,0 +1,11 @@
package projectplanning
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "project-planning",
Label: "项目规划",
OwnedDefinitionKeys: []string{"skill.project-planning.definition"},
OwnedCatalogEntries: []string{"catalog.skill.project-planning"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package projectplanning
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "project-planning",
Label: "项目规划",
PlanTitle: "项目规划流程",
PlanSteps: []string{"明确目标边界", "拆解阶段路径", "整理里程碑", "补齐风险与依赖"},
ResultTitle: "项目规划草案",
Summary: "已把目标拆成阶段计划、里程碑和风险项,方便直接进入执行或评审。",
Highlights: []string{"适合启动、排期和推进对齐", "默认沉淀阶段计划与风险清单", "结果适合继续流转到任务治理"},
ActionType: "plan",
}
@@ -0,0 +1,11 @@
package proposalsummary
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "proposal-summary",
Label: "方案摘要",
OwnedDefinitionKeys: []string{"skill.proposal-summary.definition"},
OwnedCatalogEntries: []string{"catalog.skill.proposal-summary"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package proposalsummary
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "proposal-summary",
Label: "方案摘要",
PlanTitle: "方案摘要流程",
PlanSteps: []string{"识别方案目标", "提炼核心亮点", "压缩实施路径", "输出摘要版本"},
ResultTitle: "方案摘要稿",
Summary: "已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。",
Highlights: []string{"适合方案汇报和管理层预读", "自动压缩价值点与实施路径", "默认沉淀摘要正文和亮点清单"},
ActionType: "summary",
}
@@ -0,0 +1,11 @@
package reportgeneration
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "report-generation",
Label: "报告生成",
OwnedDefinitionKeys: []string{"skill.report-generation.definition"},
OwnedCatalogEntries: []string{"catalog.skill.report-generation"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,300 @@
package reportgeneration
import (
"encoding/json"
"fmt"
"strings"
"time"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
)
// ReportGenRequest 报告生成请求
type ReportGenRequest struct {
Topic string `json:"topic"` // 报告主题,必填
Summary string `json:"summary"` // 报告摘要/背景
Context map[string]any `json:"context"` // 附加上下文
Sections []string `json:"sections"` // 期望包含的章节(可选)
Knowledge string `json:"knowledge_key"` // 关联知识库 key
}
// ReportChapter 报告章节结构
type ReportChapter struct {
Title string `json:"title"`
Body string `json:"body"`
}
// ReportContent 完整报告结构
type ReportContent struct {
Topic string `json:"topic"`
Summary string `json:"summary"`
Chapters []ReportChapter `json:"chapters"`
CreatedAt string `json:"created_at"`
}
// RetrieveReportKnowledge 为报告生成检索知识库片段
func RetrieveReportKnowledge(topic string, spaceKey string) string {
if spaceKey != "" {
// 按空间检索
var space model.KnowledgeSpace
if err := store.DB.Where("key = ?", spaceKey).First(&space).Error; err != nil {
return ""
}
var chunks []model.KnowledgeChunk
store.DB.Where("space_id = ? AND approved = ?", space.ID, true).
Order("importance DESC").
Limit(10).
Find(&chunks)
parts := make([]string, 0, len(chunks))
for _, ch := range chunks {
parts = append(parts, ch.Content)
}
return strings.Join(parts, "\n\n")
}
// 全局检索(按 title 匹配)
var chunks []model.KnowledgeChunk
store.DB.Where("approved = ?", true).
Order("importance DESC").
Limit(15).
Find(&chunks)
parts := make([]string, 0, len(chunks))
for _, ch := range chunks {
parts = append(parts, ch.Content)
}
return strings.Join(parts, "\n\n")
}
// PromptReportWithSections 使用用户指定章节生成报告
func PromptReportWithSections(req ReportGenRequest, knowledge string) []ReportChapter {
sectionsStr := strings.Join(req.Sections, "、")
systemPrompt := `你是 EAI 数字员工平台的报告生成引擎。你的任务是输出一份结构化的正式报告,语言为中文。`
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
主题:%s
报告要求:
1. 内容完整、逻辑清晰、避免空话
2. 使用正式的书面语言,适合内部汇报或培训
3. 报告必须包含以下章节(按顺序):%s
4. 每个章节下至少 3-5 个要点
5. 如有知识库资料可引用,请融入内容中
当前报告摘要/背景:%s
知识库相关资料(如有):
%s`,
req.Topic,
sectionsStr,
strings.TrimSpace(req.Summary),
knowledge,
)
aiRoute, _ := config.GetRoute("title_gen")
aiContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
if err != nil {
return fallbackReport(req.Topic)
}
return parseReportMarkdown(aiContent, req.Sections)
}
// PromptReportAutoChapters 让 LLM 自动规划报告章节
func PromptReportAutoChapters(req ReportGenRequest, knowledge string) []ReportChapter {
systemPrompt := `你是 EAI 数字员工平台的报告生成引擎。先规划报告章节,再撰写内容。输出必须严格为 JSON 数组,不要输出其他内容。`
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
主题:%s
报告要求:
1. 先规划 5-8 个合适的章节
2. 每个章节包含:标题(title)和正文(body)
3. 正文不少于 300 字
4. 使用正式的书面语言
当前报告摘要/背景:%s
知识库相关资料(如有):
%s
请输出 JSON 格式,每个条目包含 "title" 和 "body" 两个字段。`,
req.Topic,
strings.TrimSpace(req.Summary),
knowledge,
)
aiRoute, _ := config.GetRoute("title_gen")
aiContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
if err != nil {
return fallbackReport(req.Topic)
}
return parseReportJSON(aiContent)
}
// parseReportMarkdown 从 Markdown 格式解析报告为章节数组
func parseReportMarkdown(content string, defaultSections []string) []ReportChapter {
if len(defaultSections) == 0 {
defaultSections = []string{"背景概述", "核心内容", "关键数据", "实施方案", "总结建议"}
}
lines := strings.Split(content, "\n")
chapters := make([]ReportChapter, 0, len(defaultSections))
currentTitle := ""
currentBody := ""
isChapterTitle := func(line string) bool {
if strings.HasPrefix(line, "# ") || strings.HasPrefix(line, "## ") {
return true
}
runes := []rune(line)
if len(runes) >= 2 {
// 数字序号:1、 1. 一、
if (runes[0] >= '0' && runes[0] <= '9') && (runes[1] == '、' || runes[1] == '.') {
return true
}
if runes[0] == '一' && runes[1] == '、' {
return true
}
}
// 短标题带冒号(排除 URL)
if len(runes) < 30 && len(runes) > 3 && strings.Contains(line, ":") && !strings.Contains(line, "https") {
// 排除 markdown 行内代码
if !strings.Contains(line, "```") {
return true
}
}
return false
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "---") {
continue
}
if len(chapters) < 10 && isChapterTitle(line) {
if currentTitle != "" && currentBody != "" {
chapters = append(chapters, ReportChapter{
Title: strings.TrimSpace(currentTitle),
Body: strings.TrimSpace(currentBody),
})
}
currentTitle = strings.TrimLeft(line, "# \t")
currentTitle = strings.TrimRight(currentTitle, " ")
currentBody = ""
continue
}
if currentTitle != "" {
currentBody += line + "\n"
}
}
if currentTitle != "" && currentBody != "" {
chapters = append(chapters, ReportChapter{
Title: strings.TrimSpace(currentTitle),
Body: strings.TrimSpace(currentBody),
})
}
if len(chapters) == 0 {
for _, title := range defaultSections {
chapters = append(chapters, ReportChapter{
Title: title,
Body: content,
})
}
}
if len(chapters) > 15 {
chapters = chapters[:15]
}
return chapters
}
// parseReportJSON 从 LLM 返回的 JSON 解析报告
func parseReportJSON(content string) []ReportChapter {
// 提取 JSON 块
jsonStr := content
if idx := strings.Index(content, "["); idx >= 0 {
jsonStr = content[idx:]
}
if idx := strings.LastIndex(jsonStr, "]"); idx >= 0 {
jsonStr = jsonStr[:idx+1]
}
var chapters []ReportChapter
if err := json.Unmarshal([]byte(jsonStr), &chapters); err == nil && len(chapters) > 0 {
return chapters
}
// 提取数组对象块
start := strings.Index(content, "{")
end := strings.LastIndex(content, "}")
if start >= 0 && end > start {
jsonStr2 := content[start : end+1]
if err := json.Unmarshal([]byte(jsonStr2), &chapters); err == nil && len(chapters) > 0 {
return chapters
}
}
// 回退:生成默认章节
return fallbackReport("")
}
// fallbackReport 无 LLM 时的默认报告模板
func fallbackReport(topic string) []ReportChapter {
title := topic
if title == "" {
title = "分析报告"
}
return []ReportChapter{
{
Title: "一、背景概述",
Body: fmt.Sprintf("本报告围绕「%s」主题展开,旨在梳理相关背景信息与核心要点,为内部学习与汇报提供参考。", title),
},
{
Title: "二、核心内容",
Body: fmt.Sprintf("在「%s」领域,当前主流实践聚焦于以下几个维度:知识体系构建、流程标准化、工具赋能与效果评估。通过系统化的培训与考核机制,帮助团队提升专业能力。", title),
},
{
Title: "三、关键数据",
Body: "关键指标包括:产品覆盖率、培训完成率、考试通过率、知识入库率、AI 使用频次等。具体数据需根据实际运营情况定期更新。",
},
{
Title: "四、实施方案",
Body: "建议分阶段推进:第一阶段建立知识体系与培训框架;第二阶段完善考核与评估机制;第三阶段引入 AI 辅助工具提升效率;第四阶段持续优化与迭代。",
},
{
Title: "五、总结建议",
Body: fmt.Sprintf("整体而言,「%s」是当前业务发展的核心方向之一。建议持续投入资源,建立系统化培训体系,结合 AI 工具提升效率,确保团队能力与业务发展同步。", title),
},
}
}
// PromptReportPreview 预览模式:返回精简报告
func PromptReportPreview(req ReportGenRequest) ReportContent {
var chapters []ReportChapter
if len(req.Sections) > 0 {
chapters = PromptReportWithSections(req, RetrieveReportKnowledge(req.Topic, req.Knowledge))
} else {
chapters = PromptReportAutoChapters(req, RetrieveReportKnowledge(req.Topic, req.Knowledge))
}
return ReportContent{
Topic: req.Topic,
Summary: strings.TrimSpace(req.Summary),
Chapters: chapters,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
@@ -0,0 +1,258 @@
package reportgeneration
import (
"archive/zip"
"bytes"
"fmt"
"os"
"strings"
"github.com/jung-kurt/gofpdf/v2"
)
// ExportReportPDF 导出报告为 PDF 字节
func ExportReportPDF(report ReportContent) ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
fontLoaded := false
for _, candidate := range []string{
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
} {
if _, err := os.Stat(candidate); err == nil {
pdf.AddUTF8Font("czk", "", candidate)
fontLoaded = true
break
}
}
pdf.AddPage()
// 标题
if fontLoaded {
pdf.SetFont("czk", "B", 20)
} else {
pdf.SetFont("Helvetica", "B", 20)
}
pdf.CellFormat(170, 12, report.Topic, "", 1, "C", false, 0, "")
pdf.Ln(8)
// 摘要
if strings.TrimSpace(report.Summary) != "" {
if fontLoaded {
pdf.SetFont("czk", "", 10)
} else {
pdf.SetFont("Helvetica", "", 10)
}
pdf.MultiCell(170, 5, report.Summary, "", "L", false)
pdf.Ln(6)
}
// 目录
if fontLoaded {
pdf.SetFont("czk", "B", 14)
} else {
pdf.SetFont("Helvetica", "B", 14)
}
pdf.Cell(0, 8, "目录")
pdf.Ln(4)
for _, ch := range report.Chapters {
if fontLoaded {
pdf.SetFont("czk", "", 10)
} else {
pdf.SetFont("Helvetica", "", 10)
}
pdf.Cell(0, 6, " "+ch.Title)
pdf.Ln(5)
}
pdf.Ln(6)
// 章节
for _, ch := range report.Chapters {
pdf.AddPage()
if fontLoaded {
pdf.SetFont("czk", "B", 14)
} else {
pdf.SetFont("Helvetica", "B", 14)
}
pdf.Cell(0, 8, ch.Title)
pdf.Ln(2)
if fontLoaded {
pdf.SetFont("czk", "", 10)
} else {
pdf.SetFont("Helvetica", "", 10)
}
pdf.MultiCell(170, 5, ch.Body, "", "L", false)
pdf.Ln(6)
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// zipWriteFile helper: create a file in zip and write content
func zipWriteFile(zw *zip.Writer, name, content string) error {
f, err := zw.Create(name)
if err != nil {
return err
}
_, err = f.Write([]byte(content))
return err
}
// escapeXML escapes HTML/XML special characters
func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "'", "&apos;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
// timeStr returns a fixed string for pptx sldId
func timeStr() string {
return "20060102T150405"
}
func ExportReportDOCX(report ReportContent) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
zipWriteFile(zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`)
zipWriteFile(zw, "_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"/></Relationships>`)
var doc strings.Builder
doc.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="48"/></w:rPr><w:t>` + escapeXML(report.Topic) + `</w:t></w:r></w:p>`)
doc.WriteString(`<w:p><w:r><w:rPr><w:b/><w:sz w:val="24"/></w:rPr><w:t>摘要</w:t></w:r></w:p>`)
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="24"/></w:rPr><w:t>` + escapeXML(report.Summary) + `</w:t></w:r></w:p>`)
for _, ch := range report.Chapters {
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="28"/></w:rPr><w:t>` + escapeXML(ch.Title) + `</w:t></w:r></w:p>`)
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="22"/></w:rPr><w:t>` + escapeXML(ch.Body) + `</w:t></w:r></w:p>`)
}
doc.WriteString(`</w:body></w:document>`)
zipWriteFile(zw, "word/document.xml", doc.String())
zipWriteFile(zw, "word/styles.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styles xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><style type="paragraph" styleId="Title"><name val="Title"/><uiPriority uiPriority="9"/><rPr><rStyle val="Title"/><rFonts w:ascii="Arial" w:hAnsi="Arial"/><b/><sz w:val="48"/></rPr></style><style type="paragraph" styleId="Heading1"><name val="Heading 1"/><rPr><rStyle val="Heading1"/><rFonts w:ascii="Arial" w:hAnsi="Arial"/><b/><sz w:val="28"/></rPr></style></styles>`)
zipWriteFile(zw, "word/_rels/document.xml.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/styles" Target="styles.xml"/></Relationships>`)
zipWriteFile(zw, "docProps/core.xml", `<?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/"><dc:title>`+escapeXML(report.Topic)+`</dc:title></cp:coreProperties>`)
zipWriteFile(zw, "docProps/app.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Microsoft Word</Application></Properties>`)
zw.Close()
return buf.Bytes(), nil
}
func ExportReportPPTX(report ReportContent) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
layoutRefs := make([]string, 0)
for i := 0; i < len(report.Chapters)+1; i++ {
layoutRefs = append(layoutRefs, "rId"+fmt.Sprint(i+1))
}
slideRels := strings.Join(layoutRefs, "")
zipWriteFile(zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/></Types>`)
zipWriteFile(zw, "_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="ppt/presentation.xml"/></Relationships>`)
preXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" changedTime="` + timeStr() + `">` +
`<p:sldMasterId href="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"/>` +
`<p:sldIds>` + slideRels + `</p:sldIds></p:presentation>`
zipWriteFile(zw, "ppt/presentation.xml", preXML)
zipWriteFile(zw, "ppt/_rels/presentation.xml.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/slideMaster" Target="slideMaster/slideMaster1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayouts" Target="slideLayouts/slideLayout1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/></Relationships>`)
titleSlide := buildSlideTitle(report.Topic, report.Summary)
zipWriteFile(zw, "ppt/slides/slide1.xml", titleSlide)
zipWriteFile(zw, "ppt/slides/_rels/slide1.xml.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/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`)
for i, ch := range report.Chapters {
slideNum := i + 2
slidePath := fmt.Sprintf("ppt/slides/slide%d.xml", slideNum)
relnPath := fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", slideNum)
zipWriteFile(zw, slidePath, buildSlideChapter(ch.Title, ch.Body))
zipWriteFile(zw, relnPath, `<?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/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`)
}
zipWriteFile(zw, "ppt/slideLayouts/slideLayout1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" type="title"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="1219200"/></p:xfrm></p:spPr><p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr alignment="ctr"/><a:r><a:rPr lang="zh-CN" sz="4400" dirty="0"/><a:t>标题</a:t></a:r><a:endParaRPr lang="zh-CN" sz="4400" dirty="0"/></a:p></a:txBody></p:sp><p:sp><p:nvSpPr><p:cNvPr id="2" name="内容"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1524000"/><p:ext cx="6400800" cy="5334000"/></p:xfrm></p:spPr><p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2400" dirty="0"/><a:t>内容</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2400" dirty="0"/></a:p></a:txBody></p:sp></p:spTree></p:cSld></p:sldLayout>`)
zipWriteFile(zw, "ppt/theme/theme1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Design Theme"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F497D"/></a:dk2><a:lt2><a:srgbClr val="E8E8E8"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="4472C4"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="9563C1"/></a:folHlink></a:clrScheme><a:fontScheme name="Office"><a:majorFont><a:latin fontFamily="Calibri" script="latn"/><a:ea script="hani"/><a:cs fontFamily="Cambria" script="latin"/></a:majorFont><a:minorFont><a:latin fontFamily="Calibri" script="latn"/><a:ea script="hani"/><a:cs fontFamily="Cambria" script="latin"/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"/></a:themeElements></a:theme>`)
zipWriteFile(zw, "ppt/media/", "")
zw.Close()
return buf.Bytes(), nil
}
func buildSlideTitle(title, summary string) string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<p:slide xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" p:sldId="` + timeStr() + `">` +
`<p:cSld><p:spTree>` +
`<p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="1219200"/></p:xfrm></p:spPr>` +
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" anchor="ctr"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr alignment="ctr"/><a:r><a:rPr lang="zh-CN" sz="4400" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:b/><a:t>` + escapeXML(title) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="4400" dirty="0"/></a:p></a:txBody></p:sp>` +
`<p:sp><p:nvSpPr><p:cNvPr id="2" name="摘要"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1524000"/><p:ext cx="6400800" cy="5334000"/></p:xfrm></p:spPr>` +
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2400" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:t>` + escapeXML(summary) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2400" dirty="0"/></a:p></a:txBody></p:sp>` +
`</p:spTree></p:cSld></p:slide>`
}
func buildSlideChapter(title, body string) string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<p:slide xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" p:sldId="` + timeStr() + `">` +
`<p:cSld><p:spTree>` +
`<p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="914400"/></p:xfrm></p:spPr>` +
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" anchor="ctr"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr/><a:r><a:rPr lang="zh-CN" sz="3200" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:b/><a:t>` + escapeXML(title) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="3200" dirty="0"/></a:p></a:txBody></p:sp>` +
`<p:sp><p:nvSpPr><p:cNvPr id="2" name="内容"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1219200"/><p:ext cx="6400800" cy="5791200"/></p:xfrm></p:spPr>` +
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2000" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:t>` + escapeXML(body) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2000" dirty="0"/></a:p></a:txBody></p:sp>` +
`</p:spTree></p:cSld></p:slide>`
}
func ExportReportXLSX(report ReportContent) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
ss := []string{}
addSS := func(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return len(ss)
}
ss = append(ss, s)
return len(ss) - 1
}
addSS(report.Topic)
addSS(report.Summary)
for _, ch := range report.Chapters {
addSS(ch.Title)
addSS(ch.Body)
}
zipWriteFile(zw, "[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="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>`)
zipWriteFile(zw, "_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="xl/workbook.xml"/></Relationships>`)
zipWriteFile(zw, "xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><workbookPr date1904="false"/><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="25600" windowHeight="14400"/></bookViews><sheets><sheet name="报告" sheetId="1" r:id="rId1"/></sheets></workbook>`)
zipWriteFile(zw, "xl/_rels/workbook.xml.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/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>`)
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="` + fmt.Sprint(len(ss)) + `" uniqueCount="` + fmt.Sprint(len(ss)) + `">`)
for _, s := range ss {
sb.WriteString(`<si><t>` + escapeXML(s) + `</t></si>`)
}
sb.WriteString(`</sst>`)
zipWriteFile(zw, "xl/sharedStrings.xml", sb.String())
var sb2 strings.Builder
sb2.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>`)
row := 1
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A1" t="inlineStr"><is><t>` + escapeXML(report.Topic) + `</t></is></c></row>`)
row = 2
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A2" t="inlineStr"><is><t>摘要</t></is></c><c r="B2" t="inlineStr"><is><t>` + escapeXML(report.Summary) + `</t></is></c></row>`)
row = 3
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A3" t="inlineStr"><is><t>章节</t></is></c><c r="B3" t="inlineStr"><is><t>内容</t></is></c></row>`)
for _, ch := range report.Chapters {
row++
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A` + fmt.Sprint(row) + `" t="inlineStr"><is><t>` + escapeXML(ch.Title) + `</t></is></c><c r="B` + fmt.Sprint(row) + `" t="inlineStr"><is><t>` + escapeXML(ch.Body) + `</t></is></c></row>`)
}
sb2.WriteString(`</sheetData></worksheet>`)
zipWriteFile(zw, "xl/worksheets/sheet1.xml", sb2.String())
zw.Close()
return buf.Bytes(), nil
}
@@ -0,0 +1,11 @@
package smartassistant
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "smart-assistant",
Label: "通用助手",
OwnedDefinitionKeys: []string{"skill.smart-assistant.definition"},
OwnedCatalogEntries: []string{"catalog.skill.smart-assistant"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,11 @@
package surveysummary
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "survey-summary",
Label: "表单问卷汇总",
OwnedDefinitionKeys: []string{"skill.survey-summary.definition"},
OwnedCatalogEntries: []string{"catalog.skill.survey-summary"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package surveysummary
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "survey-summary",
Label: "表单问卷汇总",
PlanTitle: "问卷汇总流程",
PlanSteps: []string{"归并表单结果", "提炼重点发现", "整理问题与建议", "输出汇总版本"},
ResultTitle: "问卷汇总稿",
Summary: "已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。",
Highlights: []string{"适合反馈表、报名表、调查问卷", "自动压缩重点发现和改进建议", "默认沉淀汇总报告和行动建议"},
ActionType: "report",
}
@@ -0,0 +1,11 @@
package tablecleanup
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "table-cleanup",
Label: "表格整理",
OwnedDefinitionKeys: []string{"skill.table-cleanup.definition"},
OwnedCatalogEntries: []string{"catalog.skill.table-cleanup"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package tablecleanup
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "table-cleanup",
Label: "表格整理",
PlanTitle: "表格整理流程",
PlanSteps: []string{"识别字段结构", "清洗原始数据", "整理表格内容", "补充字段说明"},
ResultTitle: "表格整理结果",
Summary: "已把输入内容整理成结构化表格,并补了字段说明和清洗备注。",
Highlights: []string{"适合名单、台账、记录整理", "默认沉淀表格正文和字段说明", "便于继续导入 Excel 或系统"},
ActionType: "table",
}
@@ -0,0 +1,11 @@
package texttoolkit
import skillcontracts "eai_agentplatform/backend/internal/skills/contracts"
var Manifest = skillcontracts.Manifest{
Key: "text-toolkit",
Label: "文本处理",
OwnedDefinitionKeys: []string{"skill.text-toolkit.definition"},
OwnedCatalogEntries: []string{"catalog.skill.text-toolkit"},
ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory,
}
@@ -0,0 +1,14 @@
package texttoolkit
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
var OfficeRuntime = officecontracts.Runtime{
Key: "text-toolkit",
Label: "文本处理",
PlanTitle: "文本处理流程",
PlanSteps: []string{"识别清洗目标", "去除噪音符号", "统一格式与标点", "输出可复用文本"},
ResultTitle: "清洗后文本",
Summary: "已完成文本清洗、格式整理和统计,可直接复制或下载结果。",
Highlights: []string{"去 Markdown 噪音", "输出文本统计", "适合高频复制到 Word 和 IM"},
ActionType: "text",
}
@@ -0,0 +1,271 @@
package office
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
func BuildMeetingMinutesArtifacts(inputText string) []gin.H {
fields, bodyText := parseOfficeFields(inputText, map[string][]string{
"topic": {"会议主题", "主题", "title", "topic"},
"date": {"时间", "日期", "date"},
"attendees": {"参会人", "参会人员", "attendees"},
"objective": {"目标", "目的", "objective"},
})
topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "会议纪要"))
meetingDate := firstNonEmpty(fields["date"], "待补充")
attendees := firstNonEmpty(fields["attendees"], "待补充")
objective := firstNonEmpty(fields["objective"], "对齐进展、结论和行动项")
points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"确认本次会议目标和范围", "同步当前进展与关键问题", "明确下一步行动和负责人", "记录需要继续跟进的依赖项"})
actions := parseMeetingActions(firstNonEmpty(bodyText, inputText), points)
minutesContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"## 会议摘要",
fmt.Sprintf("%s 已整理为正式纪要版本,建议先快速确认结论和行动项,再同步到群里或项目任务中。", topic),
"",
"## 会议信息",
"- 时间:" + meetingDate,
"- 参会人:" + attendees,
"- 会议目标:" + objective,
"",
"## 关键结论",
"- " + firstNonEmpty(pointAt(points, 0), "确认会议主题和目标"),
"- " + firstNonEmpty(pointAt(points, 1), "同步当前进展与问题"),
"- " + firstNonEmpty(pointAt(points, 2), "明确下一步行动"),
"- " + firstNonEmpty(pointAt(points, 3), "确认依赖和配合事项"),
"",
"## 待办事项",
formatMeetingActionLine(actions, 0),
formatMeetingActionLine(actions, 1),
formatMeetingActionLine(actions, 2),
}, "\n"))
actionLines := []string{
"# 会后行动项",
"",
"| 序号 | 负责人 | 行动项 | 截止时间 | 状态 |",
"| --- | --- | --- | --- | --- |",
}
for index, item := range actions {
actionLines = append(actionLines, fmt.Sprintf("| %d | %s | %s | %s | 待开始 |", index+1, asString(item["owner"]), asString(item["task"]), asString(item["due"])))
}
actionContent := strings.TrimSpace(strings.Join(actionLines, "\n"))
syncLines := []string{
"【" + topic + "】",
"1. " + firstNonEmpty(pointAt(points, 0), "确认会议主题和目标"),
"2. " + firstNonEmpty(pointAt(points, 1), "同步当前进展与问题"),
"3. " + firstNonEmpty(pointAt(points, 2), "明确下一步行动"),
}
for _, item := range actions[:minInt(len(actions), 2)] {
syncLines = append(syncLines, fmt.Sprintf("- %s:%s(%s)", asString(item["owner"]), asString(item["task"]), asString(item["due"])))
}
syncLines = append(syncLines, "请相关同事按行动项继续推进。")
syncContent := strings.TrimSpace(strings.Join(syncLines, "\n"))
slug := officeSlug(topic, "meeting-minutes")
return []gin.H{
{
"key": "minutes",
"title": "会议纪要",
"description": "适合直接发送或继续细化的正式纪要。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": minutesContent,
},
{
"key": "actions",
"title": "行动项清单",
"description": "会后跟进和任务分派用的待办清单。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-actions.md",
"mimeType": "text/markdown;charset=utf-8",
"content": actionContent,
},
{
"key": "sync",
"title": "会后同步摘要",
"description": "适合发到群里或邮件里的简短同步版本。",
"kind": "text",
"fileFormat": "txt",
"downloadName": slug + "-sync.txt",
"mimeType": "text/plain;charset=utf-8",
"content": syncContent,
},
}
}
func BuildEmailDraftingArtifacts(inputText string) []gin.H {
fields, bodyText := parseOfficeFields(inputText, map[string][]string{
"recipient": {"收件人", "对象", "recipient", "to"},
"cc": {"抄送", "抄送人", "cc"},
"topic": {"主题", "标题", "topic", "subject"},
"purpose": {"目的", "意图", "purpose"},
"action": {"希望对方", "需要对方", "action"},
"deadline": {"时间", "截止时间", "deadline"},
"tone": {"语气", "风格", "tone", "style"},
})
topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "邮件主题"))
recipient := firstNonEmpty(fields["recipient"], "各位")
ccList := splitSimpleList(fields["cc"])
purpose := firstNonEmpty(fields["purpose"], "同步背景和当前进展")
action := firstNonEmpty(fields["action"], "请协助确认并继续推进")
deadline := firstNonEmpty(fields["deadline"], "请尽快反馈")
tone := firstNonEmpty(fields["tone"], "正式、明确、礼貌")
points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"交代背景和当前情况", "说明希望对方配合或确认的事项", "给出时间点和下一步动作", "保持礼貌但结论清楚"})
subjectVariants := buildEmailSubjectVariants(topic, purpose)
subject := subjectVariants[0]
emailContent := strings.TrimSpace(strings.Join([]string{
"主题:" + subject,
"收件人:" + recipient,
"抄送:" + firstNonEmpty(strings.Join(ccList, "、"), "无"),
"",
recipient + "好,",
"",
purpose + "。" + firstNonEmpty(pointAt(points, 0), "这边同步一下当前事项的背景和进展。"),
action + "。" + firstNonEmpty(pointAt(points, 1), "目前需要大家协助确认相关事项。"),
deadline + "。" + firstNonEmpty(pointAt(points, 2), "如无问题,请按约定时间继续推进。"),
"",
"语气建议:" + tone + "。如有问题,请直接回复沟通。",
"",
"谢谢。",
}, "\n"))
shortContent := strings.TrimSpace(strings.Join([]string{
"【" + topic + "】",
firstNonEmpty(pointAt(points, 1), "请协助确认相关事项。"),
firstNonEmpty(pointAt(points, 2), "如无问题请按时推进。"),
}, "\n"))
briefContent := strings.TrimSpace(strings.Join([]string{
"# 邮件要点",
"",
"- 主题:" + subject,
"- " + firstNonEmpty(pointAt(points, 0), "交代背景和当前情况"),
"- " + firstNonEmpty(pointAt(points, 1), "说明希望对方配合或确认的事项"),
"- " + firstNonEmpty(pointAt(points, 2), "给出时间点和下一步动作"),
"- " + firstNonEmpty(pointAt(points, 3), "保持礼貌但结论清楚"),
}, "\n"))
subjectLines := []string{
"# 主题与抄送建议",
"",
"- 收件人:" + recipient,
"- 抄送建议:" + firstNonEmpty(strings.Join(ccList, "、"), "建议抄送直属负责人 / 相关协同方"),
"",
"## 可选主题",
}
for index, item := range subjectVariants {
subjectLines = append(subjectLines, fmt.Sprintf("%d. %s", index+1, item))
}
subjectContent := strings.TrimSpace(strings.Join(subjectLines, "\n"))
slug := officeSlug(topic, "email-drafting")
return []gin.H{
{
"key": "email",
"title": "正式邮件",
"description": "可直接复制到邮箱发送的完整版本。",
"kind": "text",
"fileFormat": "txt",
"downloadName": slug + ".txt",
"mimeType": "text/plain;charset=utf-8",
"content": emailContent,
},
{
"key": "short-followup",
"title": "简短跟进版",
"description": "适合发到 IM 或群里的短消息版本。",
"kind": "text",
"fileFormat": "txt",
"downloadName": slug + "-short.txt",
"mimeType": "text/plain;charset=utf-8",
"content": shortContent,
},
{
"key": "brief",
"title": "邮件要点",
"description": "方便审批或复核邮件口径的要点版。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-brief.md",
"mimeType": "text/markdown;charset=utf-8",
"content": briefContent,
},
{
"key": "subject-variants",
"title": "主题与抄送建议",
"description": "提供多版主题候选和抄送建议。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-subject.md",
"mimeType": "text/markdown;charset=utf-8",
"content": subjectContent,
},
}
}
func BuildProgressReportArtifacts(inputText string) []gin.H {
topic := pickOfficeTopic(inputText, "进展汇报")
points := extractOfficePoints(inputText, []string{"本期完成的关键工作", "当前风险和阻塞项", "需要协同支持的事项", "下一步计划"})
reportContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"## 本期进展",
"- " + firstNonEmpty(pointAt(points, 0), "已完成本期关键工作整理"),
"- " + firstNonEmpty(pointAt(points, 1), "已同步当前风险和问题"),
"",
"## 风险与问题",
"- " + firstNonEmpty(pointAt(points, 1), "当前仍有待继续确认的风险项"),
"- " + firstNonEmpty(pointAt(points, 2), "存在需要跨团队协同支持的事项"),
"",
"## 下步计划",
"- " + firstNonEmpty(pointAt(points, 3), "按既定节奏继续推进下一步工作"),
"- 根据反馈更新排期和优先级",
}, "\n"))
summaryContent := strings.TrimSpace(strings.Join([]string{
"【" + topic + "】",
"1. " + firstNonEmpty(pointAt(points, 0), "已完成本期关键工作整理"),
"2. " + firstNonEmpty(pointAt(points, 1), "已同步当前风险和问题"),
"3. " + firstNonEmpty(pointAt(points, 3), "下一步按计划继续推进"),
}, "\n"))
checklistContent := strings.TrimSpace(strings.Join([]string{
"# 跟进清单",
"",
"- [ ] " + firstNonEmpty(pointAt(points, 1), "继续跟进风险项"),
"- [ ] " + firstNonEmpty(pointAt(points, 2), "推动跨团队协同事项"),
"- [ ] " + firstNonEmpty(pointAt(points, 3), "执行下一步计划"),
}, "\n"))
slug := officeSlug(topic, "progress-report")
return []gin.H{
{
"key": "report",
"title": "正式汇报",
"description": "适合直接发送或继续细化的正式版本。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": reportContent,
},
{
"key": "summary",
"title": "简短摘要",
"description": "适合发群或站会快速同步的简洁版。",
"kind": "text",
"fileFormat": "txt",
"downloadName": slug + "-summary.txt",
"mimeType": "text/plain;charset=utf-8",
"content": summaryContent,
},
{
"key": "checklist",
"title": "跟进清单",
"description": "需要继续推进的风险与事项列表。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-checklist.md",
"mimeType": "text/markdown;charset=utf-8",
"content": checklistContent,
},
}
}
@@ -0,0 +1,312 @@
package office
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
func BuildPPTArtifacts(inputText string) []gin.H {
fields, bodyText := parseOfficeFields(inputText, map[string][]string{
"topic": {"主题", "标题", "topic", "title"},
"audience": {"受众", "对象", "audience"},
"goal": {"目标", "用途", "goal"},
"pages": {"页数", "页面数", "slides", "pages"},
"style": {"风格", "语气", "style", "tone"},
})
topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "汇报主题"))
audience := firstNonEmpty(fields["audience"], "管理层 / 项目相关方")
goal := firstNonEmpty(fields["goal"], "帮助受众快速理解核心结论并推动下一步")
pageCount := maxInt(6, minInt(parsePositiveInt(fields["pages"], 8), 12))
style := firstNonEmpty(fields["style"], "专业、简洁、结论先行")
points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"交代背景和目标", "整理当前问题与机会", "给出解决路径与方案", "明确执行节奏与下一步"})
slides := []gin.H{
{"title": "封面", "bullets": []string{topic, "受众:" + audience, "风格:" + style}},
{"title": "背景与目标", "bullets": sliceStrings(points, 0, 2)},
{"title": "现状与问题", "bullets": sliceStrings(points, 1, 4)},
{"title": "方案路径", "bullets": sliceStrings(points, 2, 5)},
{"title": "执行计划", "bullets": []string{goal, "阶段 1:启动与对齐", "阶段 2:执行与校正"}},
{"title": "风险与下一步", "bullets": []string{"风险项与依赖", "需要支持的事项", "下一步行动清单"}},
}
for len(slides) < pageCount {
slides = append(slides[:len(slides)-1], append([]gin.H{{
"title": fmt.Sprintf("补充页 %d", len(slides)-3),
"bullets": firstNonEmptyStringSlice(sliceStrings(points, 0, 3), []string{"补充论据", "数据支撑", "案例说明"}),
}}, slides[len(slides)-1:]...)...)
}
outline := buildOfficeMarkdownOutline(topic+" PPT 大纲", slides)
notesLines := make([]string, 0, len(slides)*4)
for index, slide := range slides {
title := asString(slide["title"])
notesLines = append(notesLines, fmt.Sprintf("## 第 %d 页:%s", index+1, title))
notesLines = append(notesLines, fmt.Sprintf("- 建议讲法:先说明“%s”的核心判断,再展开 2-3 个重点。", title))
for _, bullet := range anyToStrings(slide["bullets"]) {
notesLines = append(notesLines, fmt.Sprintf("- 讲稿提示:%s", bullet))
}
notesLines = append(notesLines, "")
}
slug := officeSlug(topic, "presentation")
return []gin.H{
{
"key": "presentation",
"title": "演示文稿",
"description": "浏览器端即时生成的 PPT 文件。",
"kind": "pptx",
"fileFormat": "pptx",
"downloadName": slug + ".pptx",
"data": gin.H{
"topic": topic,
"slides": slides,
},
},
{
"key": "outline",
"title": "演示大纲",
"description": "适合先确认页面结构和内容分配。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-outline.md",
"mimeType": "text/markdown;charset=utf-8",
"content": outline,
},
{
"key": "notes",
"title": "讲稿备注",
"description": "逐页的讲解提示和过渡说明。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-notes.md",
"mimeType": "text/markdown;charset=utf-8",
"content": strings.TrimSpace(strings.Join(notesLines, "\n")),
},
}
}
func BuildMindMapArtifacts(inputText string) []gin.H {
topic := pickOfficeTopic(inputText, "任务导图")
branches := extractOfficePoints(inputText, []string{"目标与范围", "输入材料", "关键步骤", "风险与依赖"})
if len(branches) > 6 {
branches = branches[:6]
}
slug := officeSlug(topic, "mind-map")
return []gin.H{
{
"key": "mindmap-svg",
"title": "导图图片",
"description": "便于直接查看和分享的 SVG 导图。",
"kind": "svg",
"fileFormat": "svg",
"downloadName": slug + ".svg",
"mimeType": "image/svg+xml;charset=utf-8",
"content": buildMindMapSVG(topic, branches),
},
{
"key": "mindmap-outline",
"title": "导图源结构",
"description": "可继续编辑和扩展的文字结构版。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": "# " + topic + " 思维导图\n\n- " + strings.Join(branches, "\n- "),
},
}
}
func BuildProjectPlanningArtifacts(inputText string) []gin.H {
fields, bodyText := parseOfficeFields(inputText, map[string][]string{
"topic": {"项目", "项目名称", "主题", "topic"},
"deadline": {"时间要求", "截止时间", "deadline"},
"owner": {"负责人", "owner"},
"goal": {"目标", "goal"},
"start": {"开始时间", "启动时间", "start"},
})
topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "项目规划"))
deadline := firstNonEmpty(fields["deadline"], "待确认")
owner := firstNonEmpty(fields["owner"], "待指定")
goal := firstNonEmpty(fields["goal"], "先明确目标、阶段、依赖和协同方式")
start := firstNonEmpty(fields["start"], "尽快启动")
points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"明确目标、范围和成功标准", "梳理关键里程碑与交付节点", "安排执行分工与协同节奏", "识别风险与外部依赖"})
stageRows := buildProjectStageRows(points, owner, deadline)
planContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"## 项目目标",
fmt.Sprintf("围绕“%s”整理一版可执行的项目规划,先确保目标、阶段和依赖都清楚。", topic),
"- 负责人:" + owner,
"- 启动时间:" + start,
"- 时间要求:" + deadline,
"- 总体目标:" + goal,
"",
"## 阶段安排",
"### 阶段 1:启动与对齐",
"- " + firstNonEmpty(pointAt(points, 0), "明确目标和边界"),
"### 阶段 2:执行与推进",
"- " + firstNonEmpty(pointAt(points, 1), "推进关键里程碑和核心交付"),
"### 阶段 3:验收与收口",
"- " + firstNonEmpty(pointAt(points, 2), "完成验收、复盘和沉淀"),
}, "\n"))
milestoneContent := strings.TrimSpace(strings.Join([]string{
"# 里程碑清单",
"",
"1. M1:" + firstNonEmpty(pointAt(points, 0), "明确目标和边界"),
"2. M2:" + firstNonEmpty(pointAt(points, 1), "推进关键里程碑和核心交付"),
"3. M3:" + firstNonEmpty(pointAt(points, 2), "完成验收、复盘和沉淀"),
"4. M4:" + firstNonEmpty(pointAt(points, 3), "确认依赖和外部配合事项"),
}, "\n"))
riskContent := strings.TrimSpace(strings.Join([]string{
"# 风险与依赖",
"",
"- 风险 1:" + firstNonEmpty(pointAt(points, 3), "关键依赖尚未完全明确"),
"- 风险 2:跨团队协同节奏可能影响排期",
"- 建议:将关键依赖提前前置到周会或启动会确认",
}, "\n"))
scheduleLines := []string{
"# 阶段排期表",
"",
"| 阶段 | 时间窗 | 负责人 | 目标 | 交付物 |",
"| --- | --- | --- | --- | --- |",
}
for _, item := range stageRows {
scheduleLines = append(scheduleLines, fmt.Sprintf("| %s | %s | %s | %s | %s |", asString(item["stage"]), asString(item["window"]), asString(item["owner"]), asString(item["goal"]), asString(item["output"])))
}
scheduleContent := strings.TrimSpace(strings.Join(scheduleLines, "\n"))
slug := officeSlug(topic, "project-planning")
return []gin.H{
{
"key": "project-plan",
"title": "项目计划",
"description": "适合直接评审和继续细化的主计划。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": planContent,
},
{
"key": "milestones",
"title": "里程碑清单",
"description": "关键节点和交付节奏的简洁版列表。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-milestones.md",
"mimeType": "text/markdown;charset=utf-8",
"content": milestoneContent,
},
{
"key": "risks",
"title": "风险清单",
"description": "项目推进前需要确认的风险与依赖。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-risks.md",
"mimeType": "text/markdown;charset=utf-8",
"content": riskContent,
},
{
"key": "schedule",
"title": "阶段排期表",
"description": "更接近甘特视角的阶段、时间窗和交付物表。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-schedule.md",
"mimeType": "text/markdown;charset=utf-8",
"content": scheduleContent,
},
}
}
func BuildTableCleanupArtifacts(inputText string) []gin.H {
fields, bodyText := parseOfficeFields(inputText, map[string][]string{
"topic": {"主题", "标题", "topic", "title"},
"columns": {"列", "字段", "表头", "columns"},
})
topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "表格整理"))
points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"字段 1:主对象", "字段 2:当前状态", "字段 3:下一步动作", "字段 4:备注信息"})
columns := parseTableColumns(fields["columns"], points)
tableRows := parseTableRows(firstNonEmpty(bodyText, inputText), columns, points)
rows := make([]string, 0, len(tableRows))
csvRows := make([][]string, 0, len(tableRows))
for index, row := range tableRows {
cells := make([]string, 0, len(columns))
csvCellRow := []string{fmt.Sprintf("%d", index+1)}
for columnIndex := range columns {
value := ""
if columnIndex < len(row) {
value = row[columnIndex]
}
cells = append(cells, value)
csvCellRow = append(csvCellRow, value)
}
rows = append(rows, fmt.Sprintf("| %d | %s |", index+1, strings.Join(cells, " | ")))
csvRows = append(csvRows, csvCellRow)
}
csvContent := buildCSVText(append([]string{"序号"}, columns...), csvRows)
tableContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"| 序号 | " + strings.Join(columns, " | ") + " |",
"| " + strings.Join(append([]string{"---"}, repeatString("---", len(columns))...), " | ") + " |",
strings.Join(rows, "\n"),
}, "\n"))
fieldLines := []string{
"# 字段说明",
"",
"- `序号`:用于标记记录顺序",
}
for _, column := range columns {
fieldLines = append(fieldLines, fmt.Sprintf("- `%s`:本次整理后的结构化字段", column))
}
fieldsContent := strings.TrimSpace(strings.Join(fieldLines, "\n"))
notesContent := strings.TrimSpace(strings.Join([]string{
"# 清洗备注",
"",
"- 已对原始内容做字段归并",
"- 已输出 CSV 主文件,便于直接导入 Excel",
"- 建议下一步补充负责人、日期或金额等关键字段",
}, "\n"))
slug := officeSlug(topic, "table-cleanup")
return []gin.H{
{
"key": "csv",
"title": "CSV 主表",
"description": "适合直接导入 Excel 或系统的主文件。",
"kind": "text",
"fileFormat": "csv",
"downloadName": slug + ".csv",
"mimeType": "text/csv;charset=utf-8",
"content": csvContent,
},
{
"key": "table",
"title": "表格预览",
"description": "适合先快速预览整理结果的 Markdown 版本。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": tableContent,
},
{
"key": "fields",
"title": "字段说明",
"description": "记录各字段的含义和使用方式。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-fields.md",
"mimeType": "text/markdown;charset=utf-8",
"content": fieldsContent,
},
{
"key": "notes",
"title": "清洗备注",
"description": "说明本次整理和清洗做了哪些处理。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-notes.md",
"mimeType": "text/markdown;charset=utf-8",
"content": notesContent,
},
}
}
@@ -0,0 +1,488 @@
package office
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
func BuildLongformArtifacts(inputText string) []gin.H {
topic := pickOfficeTopic(inputText, "长文主题")
sections := []gin.H{
{"title": "背景与问题", "bullets": []string{"交代问题背景", "说明为什么现在要做这件事"}},
{"title": "目标与原则", "bullets": []string{"写清楚目标边界", "说明采用的基本原则"}},
{"title": "主体分析", "bullets": []string{"从现状、问题、机会三个角度展开", "补充关键证据与案例"}},
{"title": "方案与建议", "bullets": []string{"提出可执行的方案路径", "给出必要的资源与配套要求"}},
{"title": "结论与下一步", "bullets": []string{"收束核心结论", "明确下一步动作"}},
}
outline := buildOfficeMarkdownOutline(topic+" 长文大纲", sections)
lines := []string{
"# " + topic,
"",
"## 摘要",
fmt.Sprintf("%s 这项工作需要先把背景、目标、方案和执行动作组织清楚,再进一步扩写为完整正文。本稿先给出一版可继续加工的长文骨架。", topic),
"",
}
for _, section := range sections {
title := asString(section["title"])
lines = append(lines, "## "+title)
lines = append(lines, fmt.Sprintf("%s部分应围绕“%s”展开,建议先给出清晰判断,再用材料、案例和拆解步骤支撑结论。", title, topic))
for _, bullet := range anyToStrings(section["bullets"]) {
lines = append(lines, "- "+bullet)
}
lines = append(lines, "")
}
slug := officeSlug(topic, "longform")
return []gin.H{
{
"key": "outline",
"title": "章节大纲",
"description": "先确认结构是否合理。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-outline.md",
"mimeType": "text/markdown;charset=utf-8",
"content": outline,
},
{
"key": "draft",
"title": "正文草稿",
"description": "可继续细化的正文版本。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-draft.md",
"mimeType": "text/markdown;charset=utf-8",
"content": strings.TrimSpace(strings.Join(lines, "\n")),
},
}
}
func BuildTextToolkitArtifacts(inputText string) []gin.H {
cleaned := stripMarkdownText(inputText)
stats := strings.Join([]string{
"# 文本统计",
"",
fmt.Sprintf("- 字符数:%d", len([]rune(cleaned))),
fmt.Sprintf("- 行数:%d", len(splitMeaningfulLines(cleaned))),
fmt.Sprintf("- 原始长度:%d", len([]rune(strings.TrimSpace(inputText)))),
"",
"## 处理说明",
"- 已移除常见 Markdown 标记",
"- 已压缩多余空行",
"- 已保留正文可读性",
}, "\n")
slug := officeSlug(pickOfficeTopic(inputText, "cleaned-text"), "text-toolkit")
return []gin.H{
{
"key": "cleaned",
"title": "清洗后文本",
"description": "适合直接粘贴到文档或聊天工具。",
"kind": "text",
"fileFormat": "txt",
"downloadName": slug + ".txt",
"mimeType": "text/plain;charset=utf-8",
"content": cleaned,
},
{
"key": "stats",
"title": "文本统计",
"description": "记录本次清洗和统计结果。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-stats.md",
"mimeType": "text/markdown;charset=utf-8",
"content": stats,
},
}
}
func BuildProposalSummaryArtifacts(inputText string) []gin.H {
topic := pickOfficeTopic(inputText, "方案摘要")
points := extractOfficePoints(inputText, []string{"明确方案目标和解决的问题", "提炼核心价值与收益", "压缩关键实施路径", "明确需要决策的事项"})
summaryContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"## 核心结论",
fmt.Sprintf("%s 已压缩为适合汇报和快速预读的摘要版本,重点保留目标、价值点和实施建议。", topic),
"",
"## 关键亮点",
"- " + firstNonEmpty(pointAt(points, 0), "明确方案目标和解决的问题"),
"- " + firstNonEmpty(pointAt(points, 1), "提炼核心价值与收益"),
"- " + firstNonEmpty(pointAt(points, 2), "压缩关键实施路径"),
"- " + firstNonEmpty(pointAt(points, 3), "明确需要决策的事项"),
"",
"## 实施建议",
"- 建议先确认资源与排期",
"- 建议围绕高优先级场景先落一版 MVP",
"- 建议同步关键负责人做进一步评审",
}, "\n"))
highlightsContent := strings.TrimSpace(strings.Join([]string{
"# 亮点清单",
"",
"1. " + firstNonEmpty(pointAt(points, 0), "明确方案目标和解决的问题"),
"2. " + firstNonEmpty(pointAt(points, 1), "提炼核心价值与收益"),
"3. " + firstNonEmpty(pointAt(points, 2), "压缩关键实施路径"),
"4. " + firstNonEmpty(pointAt(points, 3), "明确需要决策的事项"),
}, "\n"))
briefingContent := strings.TrimSpace(strings.Join([]string{
"【" + topic + " 摘要】",
"1. " + firstNonEmpty(pointAt(points, 0), "明确方案目标和解决的问题"),
"2. " + firstNonEmpty(pointAt(points, 1), "提炼核心价值与收益"),
"3. " + firstNonEmpty(pointAt(points, 2), "压缩关键实施路径"),
"建议按上述结论进入下一步决策或评审。",
}, "\n"))
slug := officeSlug(topic, "proposal-summary")
return []gin.H{
{
"key": "summary",
"title": "方案摘要",
"description": "适合直接汇报或继续细化的主摘要。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": summaryContent,
},
{
"key": "highlights",
"title": "亮点清单",
"description": "压缩提炼后的价值点列表。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-highlights.md",
"mimeType": "text/markdown;charset=utf-8",
"content": highlightsContent,
},
{
"key": "briefing",
"title": "汇报短版",
"description": "适合发群或口头汇报前快速同步的短摘要。",
"kind": "text",
"fileFormat": "txt",
"downloadName": slug + "-briefing.txt",
"mimeType": "text/plain;charset=utf-8",
"content": briefingContent,
},
}
}
func BuildContractBriefArtifacts(inputText string) []gin.H {
fields, bodyText := parseOfficeFields(inputText, map[string][]string{
"topic": {"合同名称", "主题", "topic"},
"role": {"立场", "我方立场", "role"},
"focus": {"重点条款", "重点关注", "focus"},
"counterpart": {"对方", "合作方", "counterpart"},
})
topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "合同要点"))
role := firstNonEmpty(fields["role"], "我方视角")
focus := firstNonEmpty(fields["focus"], "付款、违约、终止、争议解决")
counterpart := firstNonEmpty(fields["counterpart"], "对方主体待确认")
points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"合同目的与合作范围", "付款与结算安排", "违约责任与赔偿约定", "终止条件与争议处理"})
focusKeywords := splitFocusKeywords(focus)
redlineItems := buildContractRedlineItems(points, focusKeywords)
summaryContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"## 审查视角",
"- 立场:" + role,
"- 重点条款:" + focus,
"- 对方主体:" + counterpart,
"",
"## 核心条款",
"- " + firstNonEmpty(pointAt(points, 0), "合同目的与合作范围"),
"- " + firstNonEmpty(pointAt(points, 1), "付款与结算安排"),
"- " + firstNonEmpty(pointAt(points, 2), "违约责任与赔偿约定"),
"- " + firstNonEmpty(pointAt(points, 3), "终止条件与争议处理"),
"",
"## 阅读建议",
"建议先快速确认付款、违约、终止三类关键条款,再进入逐条审阅。",
}, "\n"))
riskContent := strings.TrimSpace(strings.Join([]string{
"# 风险提示",
"",
"- 风险 1:" + firstNonEmpty(pointAt(points, 2), "违约责任约定可能偏重单方义务"),
"- 风险 2:" + firstNonEmpty(pointAt(points, 3), "终止或争议处理条款仍需继续确认"),
"- 建议:重点核对责任边界、赔偿上限和解除条件",
}, "\n"))
confirmContent := strings.TrimSpace(strings.Join([]string{
"# 待确认事项",
"",
"- [ ] 付款节点与验收口径是否一致",
"- [ ] 违约责任是否存在明显失衡",
"- [ ] 终止条件与争议解决地是否可接受",
}, "\n"))
redlineLines := []string{
"# 红线建议",
"",
"| 条款 | 主要风险 | 建议红线 |",
"| --- | --- | --- |",
}
for _, item := range redlineItems {
redlineLines = append(redlineLines, fmt.Sprintf("| %s | %s | %s |", asString(item["clause"]), asString(item["risk"]), asString(item["suggestion"])))
}
redlineContent := strings.TrimSpace(strings.Join(redlineLines, "\n"))
clauseReviewLines := []string{
"# 逐条审查意见",
"",
"| 条款 | 审查结论 | 主要问题 | 建议动作 |",
"| --- | --- | --- | --- |",
}
for index, item := range redlineItems {
conclusion := "建议复核"
if index == 0 {
conclusion = "需重点关注"
} else if index == 1 {
conclusion = "建议修改"
}
clauseReviewLines = append(clauseReviewLines, fmt.Sprintf("| %s | %s | %s | %s |", asString(item["clause"]), conclusion, asString(item["risk"]), asString(item["suggestion"])))
}
clauseReviewContent := strings.TrimSpace(strings.Join(clauseReviewLines, "\n"))
slug := officeSlug(topic, "contract-brief")
return []gin.H{
{
"key": "summary",
"title": "合同摘要",
"description": "适合业务和负责人快速预读的核心条款摘要。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": summaryContent,
},
{
"key": "risks",
"title": "风险提示",
"description": "提炼出的主要风险点和审查建议。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-risks.md",
"mimeType": "text/markdown;charset=utf-8",
"content": riskContent,
},
{
"key": "confirm",
"title": "待确认事项",
"description": "需要继续确认或人工复核的关键事项。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-confirm.md",
"mimeType": "text/markdown;charset=utf-8",
"content": confirmContent,
},
{
"key": "redline",
"title": "红线建议",
"description": "更接近合同审查稿的修改建议版本。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-redline.md",
"mimeType": "text/markdown;charset=utf-8",
"content": redlineContent,
},
{
"key": "clause-review",
"title": "逐条审查意见",
"description": "更像法务审查单的逐条意见表。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-review.md",
"mimeType": "text/markdown;charset=utf-8",
"content": clauseReviewContent,
},
}
}
func BuildInterviewSummaryArtifacts(inputText string) []gin.H {
topic := pickOfficeTopic(inputText, "访谈整理")
points := extractOfficePoints(inputText, []string{"受访者关注的核心问题", "当前使用中的主要痛点", "对方案或产品的积极反馈", "后续建议和动作"})
summaryContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"## 主题摘要",
"- " + firstNonEmpty(pointAt(points, 0), "受访者关注的核心问题"),
"- " + firstNonEmpty(pointAt(points, 1), "当前使用中的主要痛点"),
"- " + firstNonEmpty(pointAt(points, 2), "对方案或产品的积极反馈"),
"- " + firstNonEmpty(pointAt(points, 3), "后续建议和动作"),
"",
"## 访谈结论",
"访谈内容已压缩为主题和观点,适合继续沉淀为需求或汇报材料。",
}, "\n"))
insightContent := strings.TrimSpace(strings.Join([]string{
"# 洞察结论",
"",
"- 洞察 1:" + firstNonEmpty(pointAt(points, 0), "受访者更关注核心目标是否真正落地"),
"- 洞察 2:" + firstNonEmpty(pointAt(points, 1), "当前流程中存在明显使用阻力或理解成本"),
"- 洞察 3:" + firstNonEmpty(pointAt(points, 2), "已有价值点需要进一步明确放大"),
}, "\n"))
actionsContent := strings.TrimSpace(strings.Join([]string{
"# 后续建议",
"",
"- [ ] " + firstNonEmpty(pointAt(points, 3), "将访谈结论转成下一步行动项"),
"- [ ] 对重点洞察做进一步验证",
"- [ ] 同步相关负责人决定是否进入后续迭代",
}, "\n"))
slug := officeSlug(topic, "interview-summary")
return []gin.H{
{
"key": "summary",
"title": "访谈摘要",
"description": "访谈主题和观点的整理版本。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": summaryContent,
},
{
"key": "insights",
"title": "洞察结论",
"description": "从访谈中提炼出的关键洞察。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-insights.md",
"mimeType": "text/markdown;charset=utf-8",
"content": insightContent,
},
{
"key": "actions",
"title": "后续建议",
"description": "建议继续推进或验证的动作。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-actions.md",
"mimeType": "text/markdown;charset=utf-8",
"content": actionsContent,
},
}
}
func BuildPolicyRewriteArtifacts(inputText string) []gin.H {
topic := pickOfficeTopic(inputText, "制度改写")
points := extractOfficePoints(inputText, []string{"明确制度适用范围", "统一执行口径和条件", "补齐例外和审批规则", "强调执行责任和留痕要求"})
rewriteContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"## 制度正文",
fmt.Sprintf("%s 已重写为更清楚、统一的正式版本,适合继续评审或发布。", topic),
"",
"### 适用范围",
"- " + firstNonEmpty(pointAt(points, 0), "明确制度适用对象与范围"),
"### 执行要求",
"- " + firstNonEmpty(pointAt(points, 1), "统一执行口径和基本条件"),
"- " + firstNonEmpty(pointAt(points, 2), "补充例外处理与审批规则"),
"### 管理要求",
"- " + firstNonEmpty(pointAt(points, 3), "强调执行责任和记录留痕"),
}, "\n"))
revisionContent := strings.TrimSpace(strings.Join([]string{
"# 修订说明",
"",
"- 已统一制度结构和表达口径",
"- 已补清含糊表达,增强执行可读性",
"- 建议结合实际流程再做一次法务或管理复核",
}, "\n"))
tipsContent := strings.TrimSpace(strings.Join([]string{
"# 执行提示",
"",
"- 发布前建议确认适用范围与责任人",
"- 对例外情形建议补充审批路径",
"- 对留痕要求建议同步模板或系统入口",
}, "\n"))
slug := officeSlug(topic, "policy-rewrite")
return []gin.H{
{
"key": "rewrite",
"title": "改写正文",
"description": "适合继续评审或发布的正式版本。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": rewriteContent,
},
{
"key": "revision",
"title": "修订说明",
"description": "说明本次主要改写和统一了哪些内容。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-revision.md",
"mimeType": "text/markdown;charset=utf-8",
"content": revisionContent,
},
{
"key": "tips",
"title": "执行提示",
"description": "制度落地前需要注意的执行建议。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-tips.md",
"mimeType": "text/markdown;charset=utf-8",
"content": tipsContent,
},
}
}
func BuildSurveySummaryArtifacts(inputText string) []gin.H {
topic := pickOfficeTopic(inputText, "问卷汇总")
points := extractOfficePoints(inputText, []string{"参与者的主要反馈方向", "高频问题或不满意点", "值得保留的积极反馈", "后续改进建议"})
reportContent := strings.TrimSpace(strings.Join([]string{
"# " + topic,
"",
"## 汇总结论",
"- " + firstNonEmpty(pointAt(points, 0), "参与者的主要反馈方向"),
"- " + firstNonEmpty(pointAt(points, 1), "高频问题或不满意点"),
"- " + firstNonEmpty(pointAt(points, 2), "值得保留的积极反馈"),
"- " + firstNonEmpty(pointAt(points, 3), "后续改进建议"),
"",
"## 汇总说明",
"已把表单和问卷结果压缩成便于阅读和决策的汇总结论。",
}, "\n"))
findingsContent := strings.TrimSpace(strings.Join([]string{
"# 重点发现",
"",
"- 发现 1:" + firstNonEmpty(pointAt(points, 0), "参与者主要关注体验和执行效果"),
"- 发现 2:" + firstNonEmpty(pointAt(points, 1), "部分问题集中在流程或内容安排上"),
"- 发现 3:" + firstNonEmpty(pointAt(points, 2), "已有若干积极反馈值得继续保留"),
}, "\n"))
actionsContent := strings.TrimSpace(strings.Join([]string{
"# 改进建议",
"",
"- [ ] " + firstNonEmpty(pointAt(points, 3), "根据汇总结果制定下一步改进动作"),
"- [ ] 对高频问题安排专项优化",
"- [ ] 向负责人同步汇总结论和优先级建议",
}, "\n"))
slug := officeSlug(topic, "survey-summary")
return []gin.H{
{
"key": "report",
"title": "汇总报告",
"description": "适合直接汇报或继续细化的主报告。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + ".md",
"mimeType": "text/markdown;charset=utf-8",
"content": reportContent,
},
{
"key": "findings",
"title": "重点发现",
"description": "从表单结果中提炼出的关键发现。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-findings.md",
"mimeType": "text/markdown;charset=utf-8",
"content": findingsContent,
},
{
"key": "actions",
"title": "改进建议",
"description": "建议后续继续推进的改进行动。",
"kind": "text",
"fileFormat": "md",
"downloadName": slug + "-actions.md",
"mimeType": "text/markdown;charset=utf-8",
"content": actionsContent,
},
}
}
@@ -0,0 +1,48 @@
package office
import (
contractbrief "eai_agentplatform/backend/internal/skills/packages/contract_brief"
emaildrafting "eai_agentplatform/backend/internal/skills/packages/email_drafting"
interviewsummary "eai_agentplatform/backend/internal/skills/packages/interview_summary"
longformwriting "eai_agentplatform/backend/internal/skills/packages/longform_writing"
meetingminutes "eai_agentplatform/backend/internal/skills/packages/meeting_minutes"
mindmap "eai_agentplatform/backend/internal/skills/packages/mind_map"
ocrunderstanding "eai_agentplatform/backend/internal/skills/packages/ocr_understanding"
policyrewrite "eai_agentplatform/backend/internal/skills/packages/policy_rewrite"
pptgeneration "eai_agentplatform/backend/internal/skills/packages/ppt_generation"
progressreport "eai_agentplatform/backend/internal/skills/packages/progress_report"
projectplanning "eai_agentplatform/backend/internal/skills/packages/project_planning"
proposalsummary "eai_agentplatform/backend/internal/skills/packages/proposal_summary"
surveysummary "eai_agentplatform/backend/internal/skills/packages/survey_summary"
tablecleanup "eai_agentplatform/backend/internal/skills/packages/table_cleanup"
texttoolkit "eai_agentplatform/backend/internal/skills/packages/text_toolkit"
officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
"github.com/gin-gonic/gin"
)
func withBuild(item officecontracts.Runtime, builder func(string) []gin.H) officecontracts.Runtime {
item.Build = builder
return item
}
var builtinRegistry = NewRegistry(
withBuild(pptgeneration.OfficeRuntime, BuildPPTArtifacts),
withBuild(mindmap.OfficeRuntime, BuildMindMapArtifacts),
withBuild(longformwriting.OfficeRuntime, BuildLongformArtifacts),
withBuild(texttoolkit.OfficeRuntime, BuildTextToolkitArtifacts),
ocrunderstanding.OfficeRuntime,
withBuild(meetingminutes.OfficeRuntime, BuildMeetingMinutesArtifacts),
withBuild(projectplanning.OfficeRuntime, BuildProjectPlanningArtifacts),
withBuild(emaildrafting.OfficeRuntime, BuildEmailDraftingArtifacts),
withBuild(tablecleanup.OfficeRuntime, BuildTableCleanupArtifacts),
withBuild(proposalsummary.OfficeRuntime, BuildProposalSummaryArtifacts),
withBuild(progressreport.OfficeRuntime, BuildProgressReportArtifacts),
withBuild(contractbrief.OfficeRuntime, BuildContractBriefArtifacts),
withBuild(interviewsummary.OfficeRuntime, BuildInterviewSummaryArtifacts),
withBuild(policyrewrite.OfficeRuntime, BuildPolicyRewriteArtifacts),
withBuild(surveysummary.OfficeRuntime, BuildSurveySummaryArtifacts),
)
func BuiltinRegistry() *Registry {
return builtinRegistry
}
@@ -0,0 +1,15 @@
package contracts
import "github.com/gin-gonic/gin"
type Runtime struct {
Key string
Label string
PlanTitle string
PlanSteps []string
ResultTitle string
Summary string
Highlights []string
ActionType string
Build func(inputText string) []gin.H
}
@@ -0,0 +1,177 @@
package office
import (
"fmt"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
func parseDueText(value string) string {
match := regexp.MustCompile(`(\d{1,2}[月/-]\d{1,2}[日号]?(?:前)?|本周[一二三四五六日天](?:前)?|下周[一二三四五六日天](?:前)?|周[一二三四五六日天](?:前)?|今天|明天|后天|本周内|下周内)`).FindString(strings.TrimSpace(value))
if match == "" {
return "待定"
}
return match
}
func normalizeActionText(value string) string {
value = regexp.MustCompile(`^(action\s*\d*[::]?|跟进[::]?|负责[::]?)`).ReplaceAllString(strings.TrimSpace(value), "")
return strings.Join(strings.Fields(value), " ")
}
func parseMeetingActions(text string, fallback []string) []gin.H {
lines := splitMeaningfulLines(text)
actions := make([]gin.H, 0, len(lines))
for _, line := range lines {
cleaned := strings.TrimSpace(regexp.MustCompile(`^[•\-*+\d.)、\s]+`).ReplaceAllString(line, ""))
if cleaned == "" {
continue
}
owner := ""
task := ""
if match := regexp.MustCompile(`^([^::]{1,12})[::]\s*(.+)$`).FindStringSubmatch(cleaned); len(match) >= 3 {
owner = strings.TrimSpace(match[1])
task = strings.TrimSpace(match[2])
} else if match := regexp.MustCompile(`^(\S{1,12})\s*(负责|跟进|推进|确认|完成|提交)\s*(.+)$`).FindStringSubmatch(cleaned); len(match) >= 4 {
owner = strings.TrimSpace(match[1])
task = strings.TrimSpace(match[2] + " " + match[3])
}
due := parseDueText(cleaned)
normalizedTask := normalizeActionText(firstNonEmpty(task, cleaned))
if normalizedTask == "" {
continue
}
if owner != "" || regexp.MustCompile(`负责|跟进|推进|确认|完成|提交`).MatchString(cleaned) {
actions = append(actions, gin.H{
"owner": firstNonEmpty(owner, "待指定"),
"task": normalizedTask,
"due": due,
})
}
}
if len(actions) > 0 {
if len(actions) > 6 {
return actions[:6]
}
return actions
}
out := make([]gin.H, 0, minInt(len(fallback), 4))
for index, item := range fallback {
out = append(out, gin.H{
"owner": fmt.Sprintf("Action %d", index+1),
"task": item,
"due": "待定",
})
if len(out) >= 4 {
break
}
}
return out
}
func buildEmailSubjectVariants(topic, purpose string) []string {
base := firstNonEmpty(topic, "事项")
context := strings.TrimSpace(purpose)
items := []string{
base + " - 请协助确认",
base + " - 需要回复",
base + " - 进展同步",
}
if context != "" {
runes := []rune(context)
if len(runes) > 12 {
context = string(runes[:12])
}
items = append(items, base+" - "+context)
} else {
items = append(items, base+" - 待处理提醒")
}
return items
}
func formatMeetingActionLine(actions []gin.H, index int) string {
if index < 0 || index >= len(actions) {
return fmt.Sprintf("%d. 待补充行动项", index+1)
}
item := actions[index]
return fmt.Sprintf("%d. %s - %s(截止:%s)", index+1, asString(item["owner"]), asString(item["task"]), asString(item["due"]))
}
func splitFocusKeywords(value string) []string {
parts := regexp.MustCompile(`[,,、/;;]+`).Split(strings.TrimSpace(value), -1)
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
out = append(out, part)
}
return out
}
func buildContractRedlineItems(points, focusKeywords []string) []gin.H {
items := make([]gin.H, 0, len(focusKeywords)+3)
for _, keyword := range focusKeywords {
items = append(items, gin.H{
"clause": keyword + "条款",
"risk": fmt.Sprintf("%s相关约定需要进一步核对责任边界和执行条件。", keyword),
"suggestion": fmt.Sprintf("建议对%s条款补齐触发条件、流程口径和我方保护性表述。", keyword),
})
}
items = append(items,
gin.H{"clause": "付款条款", "risk": firstNonEmpty(pointAt(points, 1), "付款节点、验收条件或回款安排不够清楚。"), "suggestion": "建议补齐付款条件、验收口径与逾期责任,并明确发票与付款前置条件。"},
gin.H{"clause": "违约责任", "risk": firstNonEmpty(pointAt(points, 2), "违约责任和赔偿边界可能偏向单方。"), "suggestion": "建议增加责任上限、免责情形与纠正期限,避免无限责任或单方失衡。"},
gin.H{"clause": "终止与争议", "risk": firstNonEmpty(pointAt(points, 3), "终止条件或争议解决安排仍不够友好。"), "suggestion": "建议明确解除条件、提前通知期、争议解决地及继续履行安排。"},
)
out := make([]gin.H, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
clause := asString(item["clause"])
if clause == "" {
continue
}
if _, ok := seen[clause]; ok {
continue
}
seen[clause] = struct{}{}
out = append(out, item)
if len(out) >= 5 {
break
}
}
return out
}
func stripMarkdownText(text string) string {
text = strings.ReplaceAll(text, "\r", "\n")
blockCode := regexp.MustCompile("```[\\s\\S]*?```")
text = blockCode.ReplaceAllStringFunc(text, func(value string) string {
return strings.TrimSpace(strings.ReplaceAll(value, "```", ""))
})
replacers := []struct {
pattern string
value string
}{
{"`([^`]+)`", "$1"},
{"^#{1,6}\\s+", ""},
{"^\\s*[-*+]\\s+", ""},
{"^\\s*\\d+[.)]\\s+", ""},
{"\\*\\*(.*?)\\*\\*", "$1"},
{"\\*(.*?)\\*", "$1"},
{"!\\[[^\\]]*\\]\\([^)]*\\)", ""},
{"\\[([^\\]]+)\\]\\(([^)]*)\\)", "$1"},
}
for _, item := range replacers {
text = regexp.MustCompile(item.pattern).ReplaceAllString(text, item.value)
}
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
@@ -0,0 +1,379 @@
package office
import (
"fmt"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
var officeLineTrimPattern = regexp.MustCompile(`^[#>\-*\d.)、\s]+`)
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func splitMeaningfulLines(text string) []string {
items := strings.Split(strings.ReplaceAll(strings.TrimSpace(text), "\r", "\n"), "\n")
lines := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item != "" {
lines = append(lines, item)
}
}
return lines
}
func pickOfficeTopic(text, fallback string) string {
lines := splitMeaningfulLines(text)
if len(lines) == 0 {
return fallback
}
line := officeLineTrimPattern.ReplaceAllString(lines[0], "")
line = strings.TrimSpace(strings.TrimRight(line, "。!?!?."))
if line == "" {
return fallback
}
return line
}
func extractOfficePoints(text string, fallback []string) []string {
lines := splitMeaningfulLines(text)
points := make([]string, 0, 6)
for _, line := range lines {
normalized := strings.TrimSpace(regexp.MustCompile(`^[•\-*+\d.)、\s]+`).ReplaceAllString(line, ""))
if normalized != "" {
points = append(points, normalized)
}
if len(points) >= 6 {
return points
}
}
if len(points) >= 3 {
return points
}
out := make([]string, 0, len(fallback))
for _, item := range fallback {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
return out
}
func parseOfficeFields(text string, aliasMap map[string][]string) (map[string]string, string) {
result := make(map[string]string, len(aliasMap))
lines := splitMeaningfulLines(text)
bodyLines := make([]string, 0, len(lines))
for _, line := range lines {
matched := false
for canonical, aliases := range aliasMap {
for _, alias := range aliases {
pattern := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(strings.TrimSpace(alias)) + `\s*[::]\s*(.+)$`)
if groups := pattern.FindStringSubmatch(line); len(groups) == 2 {
result[canonical] = strings.TrimSpace(groups[1])
matched = true
break
}
}
if matched {
break
}
}
if !matched {
bodyLines = append(bodyLines, line)
}
}
return result, strings.TrimSpace(strings.Join(bodyLines, "\n"))
}
func parsePositiveInt(value string, fallback int) int {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
var parsed int
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func firstNonEmptyStringSlice(items []string, fallback []string) []string {
if len(items) > 0 {
return items
}
return fallback
}
func parseTableColumns(rawColumns string, points []string) []string {
columns := splitSimpleList(rawColumns)
if len(columns) >= 2 {
return columns[:minInt(len(columns), 6)]
}
inferred := make([]string, 0, 4)
for _, item := range points {
if groups := regexp.MustCompile(`^([^::]{1,12})[::]`).FindStringSubmatch(item); len(groups) == 2 {
inferred = append(inferred, strings.TrimSpace(groups[1]))
}
if len(inferred) >= 4 {
break
}
}
if len(inferred) >= 2 {
return inferred
}
return []string{"事项", "状态", "负责人", "备注"}
}
func parseTableRows(text string, columns, fallback []string) [][]string {
lines := splitMeaningfulLines(text)
rows := make([][]string, 0, len(lines))
for _, line := range lines {
if regexp.MustCompile(`^(主题|标题|topic|title|列|字段|表头|columns)\s*[::]`).MatchString(line) {
continue
}
normalized := strings.TrimSpace(regexp.MustCompile(`^[•\-*+\d.)、\s]+`).ReplaceAllString(line, ""))
values := regexp.MustCompile(`\t+|[||,,;;]+`).Split(normalized, -1)
row := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
row = append(row, value)
}
}
if len(row) >= 2 {
if len(row) > len(columns) {
row = row[:len(columns)]
}
rows = append(rows, row)
}
}
if len(rows) > 0 {
if len(rows) > 12 {
return rows[:12]
}
return rows
}
out := make([][]string, 0, minInt(len(fallback), 4))
for index, item := range fallback {
row := []string{item, "待核对", "待指定", "待确认"}
if len(row) > len(columns) {
row = row[:len(columns)]
}
out = append(out, row)
if index >= 3 {
break
}
}
return out
}
func escapeCSVCell(value string) string {
value = strings.ReplaceAll(value, `"`, `""`)
if strings.ContainsAny(value, "\",\n") {
return `"` + value + `"`
}
return value
}
func buildCSVText(columns []string, rows [][]string) string {
lines := make([]string, 0, len(rows)+1)
lines = append(lines, strings.Join(columns, ","))
for _, row := range rows {
values := make([]string, 0, len(row))
for _, cell := range row {
values = append(values, escapeCSVCell(cell))
}
lines = append(lines, strings.Join(values, ","))
}
return strings.Join(lines, "\n")
}
func splitSimpleList(value string) []string {
parts := regexp.MustCompile(`[,,、/;;\n]+`).Split(strings.TrimSpace(value), -1)
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
out = append(out, part)
}
return out
}
func buildProjectStageRows(points []string, owner, deadline string) []gin.H {
labels := []string{"阶段 1", "阶段 2", "阶段 3", "阶段 4"}
windows := []string{"第 1 周", "第 2 周", "第 3 周", firstNonEmpty(deadline, "收口前")}
rows := make([]gin.H, 0, len(labels))
for index, label := range labels {
output := "收口结论"
switch index {
case 0:
output = "目标确认"
case 1:
output = "阶段交付"
case 2:
output = "验收材料"
}
rows = append(rows, gin.H{
"stage": label,
"window": windows[index],
"goal": firstNonEmpty(pointAt(points, index), "补充"+label+"目标"),
"owner": firstNonEmpty(owner, "待指定"),
"output": output,
})
}
return rows
}
func repeatString(value string, count int) []string {
items := make([]string, 0, count)
for index := 0; index < count; index++ {
items = append(items, value)
}
return items
}
func buildOfficeMarkdownOutline(title string, sections []gin.H) string {
lines := []string{"# " + title, ""}
for _, section := range sections {
lines = append(lines, "## "+asString(section["title"]))
for _, bullet := range anyToStrings(section["bullets"]) {
lines = append(lines, "- "+bullet)
}
lines = append(lines, "")
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func buildMindMapSVG(rootTopic string, branches []string) string {
width := 1080
laneHeight := 120
height := maxInt(320, len(branches)*laneHeight+80)
leftX, rootX, rightX := 180, 540, 900
rootY := height / 2
nodes := make([]string, 0, len(branches))
for index, branch := range branches {
y := 80 + index*laneHeight
isLeft := index%2 == 0
x := rightX
lineStartX := rootX + 120
lineEndX := x - 90
if isLeft {
x = leftX
lineStartX = rootX - 120
lineEndX = x + 90
}
nodes = append(nodes,
fmt.Sprintf(`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#94a3b8" stroke-width="2" />`, lineStartX, rootY, lineEndX, y),
fmt.Sprintf(`<rect x="%d" y="%d" width="180" height="56" rx="14" fill="#ffffff" stroke="#cbd5e1" />`, x-90, y-28),
fmt.Sprintf(`<text x="%d" y="%d" text-anchor="middle" font-size="16" fill="#334155">%s</text>`, x, y+6, escapeSVGText(branch)),
)
}
return strings.TrimSpace(fmt.Sprintf(`
<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">
<rect width="100%%" height="100%%" fill="#f8fafc" />
<rect x="%d" y="%d" width="260" height="72" rx="18" fill="#2563eb" />
<text x="%d" y="%d" text-anchor="middle" font-size="22" font-weight="600" fill="#ffffff">%s</text>
%s
</svg>`, width, height, width, height, rootX-130, rootY-36, rootX, rootY+8, escapeSVGText(rootTopic), strings.Join(nodes, "\n ")))
}
func officeSlug(text, fallback string) string {
text = strings.ToLower(strings.TrimSpace(text))
text = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(text, "-")
text = strings.Trim(text, "-")
if text == "" {
return fallback
}
return text
}
func sliceStrings(items []string, start, end int) []string {
if start < 0 {
start = 0
}
if end > len(items) {
end = len(items)
}
if start >= end {
return []string{}
}
return items[start:end]
}
func pointAt(items []string, index int) string {
if index < 0 || index >= len(items) {
return ""
}
return strings.TrimSpace(items[index])
}
func anyToStrings(value any) []string {
switch items := value.(type) {
case []string:
return items
case []any:
out := make([]string, 0, len(items))
for _, item := range items {
text := asString(item)
if text != "" {
out = append(out, text)
}
}
return out
default:
return nil
}
}
func asString(value any) string {
if value == nil {
return ""
}
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
text := strings.TrimSpace(fmt.Sprint(value))
if text == "<nil>" {
return ""
}
return text
}
}
func escapeSVGText(value string) string {
replacer := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return replacer.Replace(value)
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,26 @@
package office
import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
type Registry struct {
items map[string]officecontracts.Runtime
}
func NewRegistry(items ...officecontracts.Runtime) *Registry {
registry := &Registry{items: make(map[string]officecontracts.Runtime, len(items))}
for _, item := range items {
if item.Key == "" {
continue
}
registry.items[item.Key] = item
}
return registry
}
func (r *Registry) ByKey(key string) (officecontracts.Runtime, bool) {
if r == nil {
return officecontracts.Runtime{}, false
}
item, ok := r.items[key]
return item, ok
}
@@ -0,0 +1,60 @@
package office
import (
"fmt"
"github.com/gin-gonic/gin"
officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts"
)
// BuildOfficeSkillResultMap 把一个技能运行时的定义组装成对前端结果对象的统一形态。
// 执行体本身属于各技能包(Build),这里只做结果装配,属于 office 运行时层的自有所有权。
func BuildOfficeSkillResultMap(definition officecontracts.Runtime, inputText string) gin.H {
artifacts := buildOfficeResultArtifacts(definition, inputText)
primaryArtifact := gin.H{}
if len(artifacts) > 0 {
primaryArtifact = artifacts[0]
}
return gin.H{
"status": "ready",
"skillKey": definition.Key,
"title": definition.ResultTitle,
"summary": definition.Summary,
"highlights": definition.Highlights,
"request": gin.H{
"plainText": inputText,
},
"artifacts": artifacts,
"artifact": gin.H{
"type": firstNonEmpty(asString(primaryArtifact["kind"]), definition.ActionType, "document"),
"title": firstNonEmpty(asString(primaryArtifact["title"]), definition.ResultTitle),
"fileFormat": firstNonEmpty(asString(primaryArtifact["fileFormat"]), "md"),
"preview": firstNonEmpty(asString(primaryArtifact["content"]), inputText),
},
"taskPlan": gin.H{
"title": definition.PlanTitle,
"steps": buildOfficePlanSteps(definition.PlanSteps),
},
}
}
func buildOfficeResultArtifacts(definition officecontracts.Runtime, inputText string) []gin.H {
if definition.Build != nil {
return definition.Build(inputText)
}
return []gin.H{}
}
func buildOfficePlanSteps(steps []string) []gin.H {
items := make([]gin.H, 0, len(steps))
for index, step := range steps {
items = append(items, gin.H{
"id": fmt.Sprintf("office-step-%d", index+1),
"order": index + 1,
"title": step,
"desc": "生成可继续加工的中间结果。",
})
}
return items
}