452 lines
16 KiB
Go
452 lines
16 KiB
Go
// Document-management tools (create/read/edit_document,
|
|
// create/read/edit_spreadsheet, create/read/edit_presentation) —
|
|
// Word/Excel/PowerPoint support modeled on Claude Code's
|
|
// document-editing skills. All the actual OOXML work happens in
|
|
// pkg/officekit; this file just translates tool-call arguments to/from
|
|
// it and persists bytes through the workspace service's existing
|
|
// UploadFile/DownloadFile RPCs (binary-safe []byte fields — unlike
|
|
// ReadFile/WriteFile's text-oriented string fields, which real .docx/
|
|
// .xlsx/.pptx bytes wouldn't survive intact).
|
|
//
|
|
// Deliberately, nothing here ever puts raw XML into a ToolResult.Content
|
|
// or reads raw XML out of call.Arguments — every value crossing that
|
|
// boundary is plain text (a title, a paragraph, a cell value, a short
|
|
// summary like "wrote report.docx (4 paragraphs)"). That's what keeps
|
|
// these new tools from being a new way to trigger a tool-call parse
|
|
// failure on the model's output — the historical Ollama chat-template
|
|
// "XML syntax error" failure mode described in catalog.go's comment
|
|
// above these tool defs, or vLLM's own tool-call parser choking on
|
|
// unexpected markup in an argument or result.
|
|
package toolexec
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
workspacev1 "enkly/gen/workspace/v1"
|
|
"enkly/pkg/officekit"
|
|
"enkly/pkg/vllmkit"
|
|
)
|
|
|
|
func (e *Executor) download(ctx context.Context, path string) ([]byte, error) {
|
|
resp, err := e.Workspace.DownloadFile(ctx, &workspacev1.DownloadFileRequest{Path: path})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.GetContent(), nil
|
|
}
|
|
|
|
func (e *Executor) upload(ctx context.Context, path string, data []byte) error {
|
|
_, err := e.Workspace.UploadFile(ctx, &workspacev1.UploadFileRequest{Path: path, Content: data})
|
|
return err
|
|
}
|
|
|
|
// errResult is a small helper matching the rest of this package's
|
|
// vllmkit.ToolResult{IsError: true} convention.
|
|
func errResult(name, format string, a ...any) vllmkit.ToolResult {
|
|
return vllmkit.ToolResult{Name: name, Content: fmt.Sprintf(format, a...), IsError: true}
|
|
}
|
|
|
|
// --- Word ---------------------------------------------------------
|
|
|
|
func parseParagraphArg(m map[string]any) officekit.Paragraph {
|
|
style := officekit.ParaStyle(argStr(m, "style"))
|
|
if style == "" {
|
|
style = officekit.StyleNormal
|
|
}
|
|
return officekit.Paragraph{
|
|
Text: argStr(m, "text"),
|
|
Style: style,
|
|
Bold: argBool(m, "bold"),
|
|
Italic: argBool(m, "italic"),
|
|
Underline: argBool(m, "underline"),
|
|
}
|
|
}
|
|
|
|
func (e *Executor) createDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
if !strings.HasSuffix(strings.ToLower(path), ".docx") {
|
|
return errResult(call.Name, "create_document: path must end in .docx, got %q", path)
|
|
}
|
|
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
|
return errResult(call.Name, "create_document: %s already exists; use edit_document to change it", path)
|
|
}
|
|
var paras []officekit.Paragraph
|
|
if md := argStr(call.Arguments, "markdown"); md != "" {
|
|
// Conversion path: let officekit's Markdown parser do the
|
|
// heading/bullet/paragraph structuring instead of the model
|
|
// building "paragraphs" entries by hand — see markdown.go's
|
|
// package doc for exactly what carries over and what doesn't.
|
|
paras = officekit.MarkdownToParagraphs(md)
|
|
if len(paras) == 0 {
|
|
return errResult(call.Name, "create_document: markdown produced no recognizable content (headings/paragraphs/lists)")
|
|
}
|
|
} else {
|
|
rawParas, _ := call.Arguments["paragraphs"].([]any)
|
|
paras = make([]officekit.Paragraph, 0, len(rawParas))
|
|
for _, rp := range rawParas {
|
|
if m, ok := rp.(map[string]any); ok {
|
|
paras = append(paras, parseParagraphArg(m))
|
|
}
|
|
}
|
|
}
|
|
data, err := officekit.BuildDocx(paras)
|
|
if err != nil {
|
|
return errResult(call.Name, "create_document: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, data); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d paragraph(s), %d bytes)", path, len(paras), len(data))}
|
|
}
|
|
|
|
func (e *Executor) readDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
data, err := e.download(ctx, path)
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if len(data) == 0 {
|
|
return errResult(call.Name, "read_document: %s not found or empty", path)
|
|
}
|
|
text, err := officekit.ReadDocxText(data)
|
|
if err != nil {
|
|
return errResult(call.Name, "read_document: %v", err)
|
|
}
|
|
// Surface any pending tracked changes alongside the plain text so
|
|
// a caller reviewing a document doesn't need a second tool call to
|
|
// discover it has open redlines — see redline.go's ReadDocxRevisions.
|
|
if revisions, rerr := officekit.ReadDocxRevisions(data); rerr == nil && revisions != "" {
|
|
text += "\n\n--- tracked changes pending review (edit_document: accept_all_changes / reject_all_changes) ---\n" + revisions
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: text}
|
|
}
|
|
|
|
func (e *Executor) editDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
data, err := e.download(ctx, path)
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if len(data) == 0 {
|
|
return errResult(call.Name, "edit_document: %s not found", path)
|
|
}
|
|
|
|
switch argStr(call.Arguments, "operation") {
|
|
case "append_paragraph":
|
|
p := parseParagraphArg(call.Arguments)
|
|
out, err := officekit.AppendDocxParagraph(data, p)
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_document: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s", path)}
|
|
|
|
case "replace_text":
|
|
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
|
|
if find == "" {
|
|
return errResult(call.Name, "edit_document: replace_text requires a non-empty \"find\"")
|
|
}
|
|
out, n, err := officekit.ReplaceDocxText(data, find, replace)
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_document: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("replaced text in %d paragraph(s) of %s", n, path)}
|
|
|
|
case "replace_text_tracked":
|
|
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
|
|
if find == "" {
|
|
return errResult(call.Name, "edit_document: replace_text_tracked requires a non-empty \"find\"")
|
|
}
|
|
out, n, err := officekit.ReplaceDocxTextTracked(data, find, replace, argStr(call.Arguments, "author"))
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_document: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("tracked a change in %d run(s) of %s, pending review (Word: Review > Track Changes)", n, path)}
|
|
|
|
case "append_paragraph_tracked":
|
|
p := parseParagraphArg(call.Arguments)
|
|
out, err := officekit.AppendDocxParagraphTracked(data, p, argStr(call.Arguments, "author"))
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_document: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s as a tracked insertion, pending review", path)}
|
|
|
|
case "accept_all_changes":
|
|
out, err := officekit.AcceptAllDocxChanges(data)
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_document: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("accepted all tracked changes in %s", path)}
|
|
|
|
case "reject_all_changes":
|
|
out, err := officekit.RejectAllDocxChanges(data)
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_document: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("rejected all tracked changes in %s (original text restored)", path)}
|
|
|
|
default:
|
|
return errResult(call.Name, "edit_document: unknown operation %q (expected append_paragraph, replace_text, append_paragraph_tracked, replace_text_tracked, accept_all_changes, or reject_all_changes)", argStr(call.Arguments, "operation"))
|
|
}
|
|
}
|
|
|
|
// --- Excel ----------------------------------------------------------
|
|
|
|
func (e *Executor) createSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
if !strings.HasSuffix(strings.ToLower(path), ".xlsx") {
|
|
return errResult(call.Name, "create_spreadsheet: path must end in .xlsx, got %q", path)
|
|
}
|
|
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
|
return errResult(call.Name, "create_spreadsheet: %s already exists; use edit_spreadsheet to change it", path)
|
|
}
|
|
|
|
sheets := parseSheetsArg(call.Arguments)
|
|
if len(sheets) == 0 {
|
|
return errResult(call.Name, "create_spreadsheet: give either \"rows\" (one sheet) or \"sheets\" (multiple)")
|
|
}
|
|
data, err := officekit.BuildXlsx(sheets)
|
|
if err != nil {
|
|
return errResult(call.Name, "create_spreadsheet: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, data); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
totalRows := 0
|
|
for _, s := range sheets {
|
|
totalRows += len(s.Rows)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d sheet(s), %d row(s) total, %d bytes)", path, len(sheets), totalRows, len(data))}
|
|
}
|
|
|
|
// parseSheetsArg accepts either the multi-sheet "sheets" array
|
|
// (each {name, rows}) or the original single-sheet "rows"/"sheet_name"
|
|
// pair, so existing callers keep working unchanged while the model can
|
|
// now also build a workbook with more than one tab in one call.
|
|
func parseSheetsArg(args map[string]any) []officekit.Sheet {
|
|
if rawSheets, ok := args["sheets"].([]any); ok && len(rawSheets) > 0 {
|
|
sheets := make([]officekit.Sheet, 0, len(rawSheets))
|
|
for _, rs := range rawSheets {
|
|
m, ok := rs.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
sheets = append(sheets, officekit.Sheet{Name: argStr(m, "name"), Rows: parseRowsArg(m["rows"])})
|
|
}
|
|
return sheets
|
|
}
|
|
if _, has := args["rows"]; !has {
|
|
return nil
|
|
}
|
|
return []officekit.Sheet{{Name: argStr(args, "sheet_name"), Rows: parseRowsArg(args["rows"])}}
|
|
}
|
|
|
|
func parseRowsArg(v any) [][]string {
|
|
rawRows, _ := v.([]any)
|
|
rows := make([][]string, 0, len(rawRows))
|
|
for _, rr := range rawRows {
|
|
rawRow, _ := rr.([]any)
|
|
row := make([]string, 0, len(rawRow))
|
|
for _, c := range rawRow {
|
|
s, _ := c.(string)
|
|
row = append(row, s)
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (e *Executor) readSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
data, err := e.download(ctx, path)
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if len(data) == 0 {
|
|
return errResult(call.Name, "read_spreadsheet: %s not found or empty", path)
|
|
}
|
|
sheetRef := argStr(call.Arguments, "sheet")
|
|
rows, sheetName, err := officekit.ReadXlsxCells(data, sheetRef)
|
|
if err != nil {
|
|
return errResult(call.Name, "read_spreadsheet: %v", err)
|
|
}
|
|
var sb strings.Builder
|
|
if all, nameErr := officekit.ReadXlsxSheetNames(data); nameErr == nil && len(all) > 1 {
|
|
fmt.Fprintf(&sb, "# sheet %q of %d: %s\n", sheetName, len(all), strings.Join(all, ", "))
|
|
}
|
|
for _, row := range rows {
|
|
sb.WriteString(strings.Join(row, "\t"))
|
|
sb.WriteByte('\n')
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
|
}
|
|
|
|
func (e *Executor) editSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
data, err := e.download(ctx, path)
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if len(data) == 0 {
|
|
return errResult(call.Name, "edit_spreadsheet: %s not found", path)
|
|
}
|
|
rawCells, _ := call.Arguments["cells"].([]any)
|
|
writes := make(map[string]string, len(rawCells))
|
|
for _, rc := range rawCells {
|
|
m, ok := rc.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
writes[strings.ToUpper(argStr(m, "cell"))] = argStr(m, "value")
|
|
}
|
|
if len(writes) == 0 {
|
|
return errResult(call.Name, "edit_spreadsheet: no cells given")
|
|
}
|
|
sheetRef := argStr(call.Arguments, "sheet")
|
|
out, err := officekit.SetXlsxCells(data, sheetRef, writes)
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_spreadsheet: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d cell(s) to %s", len(writes), path)}
|
|
}
|
|
|
|
// --- PowerPoint -------------------------------------------------------
|
|
|
|
func parseSlideArg(m map[string]any) officekit.Slide {
|
|
rawBullets, _ := m["bullets"].([]any)
|
|
bullets := make([]string, 0, len(rawBullets))
|
|
for _, b := range rawBullets {
|
|
if s, ok := b.(string); ok {
|
|
bullets = append(bullets, s)
|
|
}
|
|
}
|
|
return officekit.Slide{Title: argStr(m, "title"), Bullets: bullets}
|
|
}
|
|
|
|
func (e *Executor) createPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
if !strings.HasSuffix(strings.ToLower(path), ".pptx") {
|
|
return errResult(call.Name, "create_presentation: path must end in .pptx, got %q", path)
|
|
}
|
|
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
|
return errResult(call.Name, "create_presentation: %s already exists; use edit_presentation to change it", path)
|
|
}
|
|
rawSlides, _ := call.Arguments["slides"].([]any)
|
|
slides := make([]officekit.Slide, 0, len(rawSlides))
|
|
for _, rs := range rawSlides {
|
|
if m, ok := rs.(map[string]any); ok {
|
|
slides = append(slides, parseSlideArg(m))
|
|
}
|
|
}
|
|
data, err := officekit.BuildPptx(slides)
|
|
if err != nil {
|
|
return errResult(call.Name, "create_presentation: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, data); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d slide(s), %d bytes)", path, len(slides), len(data))}
|
|
}
|
|
|
|
func (e *Executor) readPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
data, err := e.download(ctx, path)
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if len(data) == 0 {
|
|
return errResult(call.Name, "read_presentation: %s not found or empty", path)
|
|
}
|
|
slides, err := officekit.ReadPptxSlides(data)
|
|
if err != nil {
|
|
return errResult(call.Name, "read_presentation: %v", err)
|
|
}
|
|
var sb strings.Builder
|
|
for i, s := range slides {
|
|
fmt.Fprintf(&sb, "## Slide %d: %s\n", i+1, s.Title)
|
|
for _, b := range s.Bullets {
|
|
fmt.Fprintf(&sb, "- %s\n", b)
|
|
}
|
|
sb.WriteByte('\n')
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
|
}
|
|
|
|
func (e *Executor) editPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
data, err := e.download(ctx, path)
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if len(data) == 0 {
|
|
return errResult(call.Name, "edit_presentation: %s not found", path)
|
|
}
|
|
|
|
slide := parseSlideArg(call.Arguments)
|
|
switch argStr(call.Arguments, "operation") {
|
|
case "add_slide":
|
|
out, err := officekit.AppendPptxSlide(data, slide)
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_presentation: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("added 1 slide to %s", path)}
|
|
|
|
case "update_slide":
|
|
idx := argInt(call.Arguments, "index")
|
|
out, err := officekit.UpdatePptxSlide(data, idx, slide)
|
|
if err != nil {
|
|
return errResult(call.Name, "edit_presentation: %v", err)
|
|
}
|
|
if err := e.upload(ctx, path, out); err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("updated slide %d of %s", idx, path)}
|
|
|
|
default:
|
|
return errResult(call.Name, "edit_presentation: unknown operation %q (expected add_slide or update_slide)", argStr(call.Arguments, "operation"))
|
|
}
|
|
}
|
|
|
|
// --- PDF (read-only: data extraction, not creation) --------------------
|
|
|
|
func (e *Executor) readPdf(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
path := argStr(call.Arguments, "path")
|
|
data, err := e.download(ctx, path)
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if len(data) == 0 {
|
|
return errResult(call.Name, "read_pdf: %s not found or empty", path)
|
|
}
|
|
text, err := officekit.ReadPdfText(data)
|
|
if err != nil {
|
|
return errResult(call.Name, "read_pdf: %v", err)
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: text}
|
|
}
|