313 lines
10 KiB
Go
313 lines
10 KiB
Go
// Package handlers (deliverables.go) replaces
|
|
// internal/handlers/deliverables.go. Beyond the original change
|
|
// (fileDeliverable's os.Stat call becoming a Workspace.StatFile gRPC
|
|
// call), producedPath now also recognizes the nine office-document
|
|
// tools (create_document/read_document/edit_document,
|
|
// create_spreadsheet/read_spreadsheet/edit_spreadsheet,
|
|
// create_presentation/read_presentation/edit_presentation —
|
|
// toolexec/office.go) in addition to the original
|
|
// write_file/edit_file/multi_edit_file. Those tools were added
|
|
// without a matching update here: their success messages ("created
|
|
// report.docx (4 paragraph(s), 512 bytes)", "wrote 3 cell(s) to
|
|
// budget.xlsx", "added 1 slide to deck.pptx", ...) never matched
|
|
// writeResultRe/editResultRe, so officekit would build and upload a
|
|
// perfectly valid .docx/.xlsx/.pptx (confirmed by officekit's own
|
|
// tests) and the chat UI would still never show a download card for
|
|
// it — the file existed in the workspace but was never surfaced as a
|
|
// deliverable. officeResultPathRe below matches on the one thing
|
|
// every one of those success messages reliably contains — the
|
|
// workspace path itself, recognizable by its .docx/.xlsx/.pptx
|
|
// extension — rather than each operation's own differently-worded
|
|
// phrasing, so it doesn't need one regex per operation and won't
|
|
// break again the next time an office tool's wording changes.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"html/template"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
agentv1 "enkly/gen/agent/v1"
|
|
workspacev1 "enkly/gen/workspace/v1"
|
|
"enkly/pkg/renderkit"
|
|
)
|
|
|
|
const (
|
|
maxFileCards = 8
|
|
maxSnippetCards = 4
|
|
minSnippetLines = 3
|
|
)
|
|
|
|
type deliverableView struct {
|
|
Name string
|
|
TypeLabel string
|
|
Detail string
|
|
DownloadURL string
|
|
SnippetIndex int
|
|
}
|
|
|
|
var (
|
|
writeResultRe = regexp.MustCompile(`^wrote \d+ bytes to (.+)$`)
|
|
editResultRe = regexp.MustCompile(`^edited (.+?): (?:replaced|applied) `)
|
|
|
|
// officeResultPathRe pulls the workspace path out of any
|
|
// create_document/edit_document/create_spreadsheet/
|
|
// edit_spreadsheet/create_presentation/edit_presentation success
|
|
// message by matching the path itself (anything non-whitespace
|
|
// ending in .docx, .xlsx or .pptx) rather than the surrounding
|
|
// sentence, since office.go's nine tool handlers each phrase
|
|
// their result differently ("created %s (...)", "appended 1
|
|
// paragraph to %s", "wrote %d cell(s) to %s", "added 1 slide to
|
|
// %s", "updated slide %d of %s", ...) and every one of those
|
|
// phrasings always includes exactly that path.
|
|
officeResultPathRe = regexp.MustCompile(`\S+\.(?:docx|xlsx|pptx)\b`)
|
|
|
|
// officeDocTools is every office-document tool whose success
|
|
// message should be checked against officeResultPathRe.
|
|
// read_document/read_spreadsheet/read_presentation are excluded
|
|
// deliberately: those tools don't produce or change a file, so
|
|
// offering a download card for one would be misleading (and
|
|
// read_document's/read_spreadsheet's own result text can
|
|
// legitimately quote a .docx/.xlsx path from inside the document
|
|
// being read, which officeResultPathRe would otherwise mismatch
|
|
// as something just produced).
|
|
officeDocTools = map[string]bool{
|
|
"create_document": true,
|
|
"edit_document": true,
|
|
"create_spreadsheet": true,
|
|
"edit_spreadsheet": true,
|
|
"create_presentation": true,
|
|
"edit_presentation": true,
|
|
}
|
|
)
|
|
|
|
// producedPath replaces handlers.producedPath.
|
|
func producedPath(toolName, content string) string {
|
|
first, _, _ := strings.Cut(content, "\n")
|
|
if officeDocTools[toolName] {
|
|
return officeResultPathRe.FindString(first)
|
|
}
|
|
var m []string
|
|
switch toolName {
|
|
case "write_file":
|
|
m = writeResultRe.FindStringSubmatch(first)
|
|
case "edit_file", "multi_edit_file":
|
|
m = editResultRe.FindStringSubmatch(first)
|
|
}
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(m[1])
|
|
}
|
|
|
|
// cleanWorkspacePath normalizes a tool-reported path the way
|
|
// fileDeliverable and deliverablesHTMLFull already did inline —
|
|
// factored out so liveDeliverable and remainingToolCalls (chat.go's
|
|
// per-tool-call live delivery) can dedupe against the exact same key.
|
|
func cleanWorkspacePath(rel string) string {
|
|
return strings.TrimPrefix(path.Clean("/"+rel), "/")
|
|
}
|
|
|
|
func (h *Handlers) fileDeliverable(ctx context.Context, rel string) (deliverableView, bool) {
|
|
clean := cleanWorkspacePath(rel)
|
|
if clean == "" {
|
|
return deliverableView{}, false
|
|
}
|
|
stat, err := h.Workspace.StatFile(ctx, &workspacev1.StatFileRequest{Path: clean})
|
|
if err != nil || !stat.GetExists() || stat.GetIsDir() {
|
|
return deliverableView{}, false
|
|
}
|
|
name := path.Base(clean)
|
|
label := strings.ToUpper(strings.TrimPrefix(path.Ext(name), "."))
|
|
if label == "" {
|
|
label = "FILE"
|
|
}
|
|
return deliverableView{
|
|
Name: name, TypeLabel: label, Detail: humanBytes(stat.GetSize()), DownloadURL: downloadURLFor(clean),
|
|
}, true
|
|
}
|
|
|
|
// deliverablesHTML renders the card strip for the given produced paths
|
|
// and snippets, or "" when there's nothing worth showing.
|
|
func (h *Handlers) deliverablesHTMLFull(ctx context.Context, paths []string, snippets []renderkit.Snippet) string {
|
|
var views []deliverableView
|
|
|
|
seen := map[string]bool{}
|
|
for _, p := range paths {
|
|
key := cleanWorkspacePath(p)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
if v, ok := h.fileDeliverable(ctx, p); ok {
|
|
views = append(views, v)
|
|
if len(views) == maxFileCards {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var picked []renderkit.Snippet
|
|
for _, sn := range snippets {
|
|
if sn.Lines >= minSnippetLines && len(picked) < maxSnippetCards {
|
|
picked = append(picked, sn)
|
|
}
|
|
}
|
|
for n, sn := range picked {
|
|
name := "snippet" + sn.Ext
|
|
if len(picked) > 1 {
|
|
name = "snippet-" + itoa(n+1) + sn.Ext
|
|
}
|
|
if sn.Filename != "" {
|
|
name = sn.Filename
|
|
}
|
|
views = append(views, deliverableView{
|
|
Name: name, TypeLabel: sn.Label, Detail: itoa(sn.Lines) + " lines", SnippetIndex: sn.Index,
|
|
})
|
|
}
|
|
|
|
if len(views) == 0 {
|
|
return ""
|
|
}
|
|
return h.renderString("deliverables", views)
|
|
}
|
|
|
|
// deliverablesHTML is the entry point chat.go's StreamChat calls on the
|
|
// "done" event: every tool call made during the turn becomes a set of
|
|
// produced-file candidates (producedPath filters down to the
|
|
// successful write_file/edit_file/multi_edit_file ones).
|
|
func (h *Handlers) deliverablesHTML(ctx context.Context, toolCalls []*agentv1.ToolCall) string {
|
|
var paths []string
|
|
for _, tc := range toolCalls {
|
|
if tc.GetIsError() {
|
|
continue
|
|
}
|
|
if p := producedPath(tc.GetName(), tc.GetResult()); p != "" {
|
|
paths = append(paths, p)
|
|
}
|
|
}
|
|
return h.deliverablesHTMLFull(ctx, paths, nil)
|
|
}
|
|
|
|
// liveDeliverable checks whether tc — a tool call that just finished,
|
|
// mid-turn — produced or changed a file worth a download card, and
|
|
// one that hasn't already been shown live earlier in this same turn
|
|
// (shown, keyed by cleanWorkspacePath, is the running set chat.go's
|
|
// StreamChat keeps across the whole stream). This is what makes a
|
|
// document's download button appear as soon as the tool call that
|
|
// created it completes — see chat.go's TurnEvent_ToolCall case —
|
|
// rather than only once the whole turn finishes and the "done" event
|
|
// fires, which could be much later if the agent keeps working after
|
|
// creating the file. ok is false when there's nothing new to show: no
|
|
// path, an error result, a path already shown, or (StatFile coming
|
|
// back empty) a path that doesn't actually resolve to a file yet.
|
|
func (h *Handlers) liveDeliverable(ctx context.Context, streamID string, tc *agentv1.ToolCall, shown map[string]bool) (frag, key string, ok bool) {
|
|
if tc.GetIsError() {
|
|
return "", "", false
|
|
}
|
|
p := producedPath(tc.GetName(), tc.GetResult())
|
|
if p == "" {
|
|
return "", "", false
|
|
}
|
|
key = cleanWorkspacePath(p)
|
|
if shown[key] {
|
|
return "", "", false
|
|
}
|
|
v, exists := h.fileDeliverable(ctx, p)
|
|
if !exists {
|
|
return "", "", false
|
|
}
|
|
return h.renderString("deliverable_oob", struct {
|
|
StreamID string
|
|
Views []deliverableView
|
|
}{StreamID: streamID, Views: []deliverableView{v}}), key, true
|
|
}
|
|
|
|
// remainingToolCalls filters toolCalls down to the ones not already
|
|
// covered by shown, so chat.go's "done" event — which otherwise
|
|
// re-derives deliverable cards from every tool call of the whole turn
|
|
// — doesn't render a second, duplicate card for a file whose card
|
|
// already streamed live via liveDeliverable. On a resumed stream
|
|
// (StreamTurn's ResumeFromToolIndex), shown starts empty for the new
|
|
// connection, so a file created before the client reconnected still
|
|
// gets its card here as a fallback — nothing is ever silently lost,
|
|
// only de-duplicated on the common, unbroken-connection path.
|
|
func remainingToolCalls(toolCalls []*agentv1.ToolCall, shown map[string]bool) []*agentv1.ToolCall {
|
|
if len(shown) == 0 {
|
|
return toolCalls
|
|
}
|
|
out := make([]*agentv1.ToolCall, 0, len(toolCalls))
|
|
for _, tc := range toolCalls {
|
|
if !tc.GetIsError() {
|
|
if p := producedPath(tc.GetName(), tc.GetResult()); p != "" && shown[cleanWorkspacePath(p)] {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, tc)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
neg := n < 0
|
|
if neg {
|
|
n = -n
|
|
}
|
|
var b [20]byte
|
|
i := len(b)
|
|
for n > 0 {
|
|
i--
|
|
b[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
if neg {
|
|
i--
|
|
b[i] = '-'
|
|
}
|
|
return string(b[i:])
|
|
}
|
|
|
|
// withDeliverables replaces Handlers.withDeliverables +
|
|
// Handlers.attachDeliverables: appends download cards to already-
|
|
// rendered messages when a conversation page is loaded.
|
|
func (h *Handlers) withDeliverables(ctx context.Context, msgs []messageView) []messageView {
|
|
h.attachDeliverables(ctx, msgs)
|
|
return msgs
|
|
}
|
|
|
|
func (h *Handlers) attachDeliverables(ctx context.Context, msgs []messageView) {
|
|
var (
|
|
pending []string
|
|
last = -1
|
|
)
|
|
flush := func() {
|
|
if last >= 0 && len(pending) > 0 {
|
|
if cards := h.deliverablesHTMLFull(ctx, pending, nil); cards != "" {
|
|
msgs[last].RenderedHTML += template.HTML(cards) // #nosec G203 -- rendered by html/template
|
|
}
|
|
}
|
|
pending, last = nil, -1
|
|
}
|
|
for i := range msgs {
|
|
switch msgs[i].Role {
|
|
case "user":
|
|
flush()
|
|
case "tool":
|
|
if p := producedPath(msgs[i].ToolName, msgs[i].Content); p != "" {
|
|
pending = append(pending, p)
|
|
}
|
|
case "assistant":
|
|
if cards := h.deliverablesHTMLFull(ctx, nil, renderkit.Snippets(msgs[i].Content)); cards != "" {
|
|
msgs[i].RenderedHTML += template.HTML(cards) // #nosec G203 -- rendered by html/template
|
|
}
|
|
last = i
|
|
}
|
|
}
|
|
flush()
|
|
}
|