mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
tidy some functions in assistant tools package
This commit is contained in:
parent
366701c267
commit
bf035ad0ee
|
|
@ -111,6 +111,55 @@ const (
|
|||
BlockParamList Type = "split_doc_param_list"
|
||||
)
|
||||
|
||||
// Label turns the type into a friendlier label for the confirm UI.
|
||||
// Unknown types fall through verbatim.
|
||||
func (t Type) Label() string {
|
||||
switch t {
|
||||
case BlockParagraph:
|
||||
return "a paragraph"
|
||||
case BlockHeading:
|
||||
return "a heading"
|
||||
case BlockBlockquote:
|
||||
return "a blockquote"
|
||||
case BlockBulletList:
|
||||
return "a bullet list"
|
||||
case BlockOrderedList:
|
||||
return "an ordered list"
|
||||
case BlockTaskList:
|
||||
return "a task list"
|
||||
case BlockCallout:
|
||||
return "a callout"
|
||||
case BlockCode:
|
||||
return "a code block"
|
||||
case BlockTitledCode:
|
||||
return "a titled code block"
|
||||
case BlockMermaid:
|
||||
return "a mermaid diagram"
|
||||
case BlockHorizontalRule:
|
||||
return "a divider"
|
||||
case BlockImage:
|
||||
return "an image"
|
||||
case BlockFile:
|
||||
return "a file attachment"
|
||||
case BlockFigma:
|
||||
return "a figma embed"
|
||||
case BlockMetric:
|
||||
return "a metric"
|
||||
case BlockMetricGrid:
|
||||
return "a metric grid"
|
||||
case BlockSplitDoc:
|
||||
return "a split documentation block"
|
||||
case BlockParamList:
|
||||
return "a parameter list"
|
||||
}
|
||||
|
||||
if t == "" {
|
||||
return "a block"
|
||||
}
|
||||
|
||||
return "a " + string(t) + " block"
|
||||
}
|
||||
|
||||
// Block is the canonical representation of a single block as the AI
|
||||
// reads and writes it. Not every field is meaningful for every block
|
||||
// type:
|
||||
|
|
|
|||
|
|
@ -92,3 +92,39 @@ func Test_Block_CollectAttributeValues(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Type_Label(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Kind Type
|
||||
Result string
|
||||
}{
|
||||
"Paragraph": {Kind: "paragraph", Result: "a paragraph"},
|
||||
"Heading": {Kind: "heading", Result: "a heading"},
|
||||
"Blockquote": {Kind: "blockquote", Result: "a blockquote"},
|
||||
"Bullet list": {Kind: "bullet_list", Result: "a bullet list"},
|
||||
"Ordered list": {Kind: "ordered_list", Result: "an ordered list"},
|
||||
"Task list": {Kind: "task_list", Result: "a task list"},
|
||||
"Callout": {Kind: "callout", Result: "a callout"},
|
||||
"Code": {Kind: "code", Result: "a code block"},
|
||||
"Titled code": {Kind: "titled_code", Result: "a titled code block"},
|
||||
"Mermaid": {Kind: "mermaid", Result: "a mermaid diagram"},
|
||||
"Horizontal rule": {Kind: "horizontal_rule", Result: "a divider"},
|
||||
"Image": {Kind: "image", Result: "an image"},
|
||||
"File": {Kind: "file", Result: "a file attachment"},
|
||||
"Figma": {Kind: "figma", Result: "a figma embed"},
|
||||
"Metric": {Kind: "metric", Result: "a metric"},
|
||||
"Metric grid": {Kind: "metric_grid", Result: "a metric grid"},
|
||||
"Split doc": {Kind: "split_doc", Result: "a split documentation block"},
|
||||
"Param list": {Kind: "split_doc_param_list", Result: "a parameter list"},
|
||||
"Empty": {Kind: "", Result: "a block"},
|
||||
"Unknown": {Kind: "wibble", Result: "a wibble block"},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, c.Kind.Label())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,16 +30,6 @@ type Result struct {
|
|||
Errors []OpError `json:"errors"`
|
||||
}
|
||||
|
||||
// OpError describes one operation's failure on the Node side.
|
||||
type OpError struct {
|
||||
// Index is the position of the failing op in the request's
|
||||
// operations array.
|
||||
Index int `json:"index"`
|
||||
|
||||
// Message is the short reason from Node.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Client posts batched operations to the Node hocuspocus service's
|
||||
// internal operations endpoint. The base URL points at the Node
|
||||
// service (e.g. http://auth-realtime:8081); endpoint replaces its
|
||||
|
|
|
|||
48
server/core/internal/assistant/edit/error.go
Normal file
48
server/core/internal/assistant/edit/error.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package edit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpError describes one operation's failure on the Node side.
|
||||
type OpError struct {
|
||||
// Index is the position of the failing op in the request's
|
||||
// operations array.
|
||||
Index int `json:"index"`
|
||||
|
||||
// Message is the short reason from Node.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// describe rewrites the Node-side message into what the model can act
|
||||
// on: a uid it holds no block for points at get_document, a reference
|
||||
// inside the moved block says what to pick instead, and an operation
|
||||
// kind is named as the tool the model knows it by. A message with no
|
||||
// rewrite passes through as it is.
|
||||
func (e OpError) describe() string {
|
||||
for _, prefix := range []string{"reference_uid not found: ", "block_uid not found: "} {
|
||||
if uid, ok := strings.CutPrefix(e.Message, prefix); ok {
|
||||
return fmt.Sprintf("no block with uid %s in this document; call get_document for the current uids", uid)
|
||||
}
|
||||
}
|
||||
|
||||
if uid, ok := strings.CutPrefix(e.Message, "reference_uid is inside the moved block: "); ok {
|
||||
return fmt.Sprintf("reference block %s sits inside the block being moved; choose a reference outside it", uid)
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(e.Message, "update_text", "update_block_text")
|
||||
}
|
||||
|
||||
// JoinOpErrors renders per-operation failures as one message. The
|
||||
// index is left out: a tool sends a single operation, so naming its
|
||||
// position says nothing the reader can act on.
|
||||
func JoinOpErrors(errs []OpError) string {
|
||||
msgs := make([]string, 0, len(errs))
|
||||
|
||||
for _, e := range errs {
|
||||
msgs = append(msgs, e.describe())
|
||||
}
|
||||
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
58
server/core/internal/assistant/edit/error_test.go
Normal file
58
server/core/internal/assistant/edit/error_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package edit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_OpError_describe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Msg string
|
||||
Result string
|
||||
}{
|
||||
"Reference uid not found": {
|
||||
Msg: "reference_uid not found: r1",
|
||||
Result: "no block with uid r1 in this document; call get_document for the current uids",
|
||||
},
|
||||
"Block uid not found": {
|
||||
Msg: "block_uid not found: b1",
|
||||
Result: "no block with uid b1 in this document; call get_document for the current uids",
|
||||
},
|
||||
"Reference inside the moved block": {
|
||||
Msg: "reference_uid is inside the moved block: r1",
|
||||
Result: "reference block r1 sits inside the block being moved; choose a reference outside it",
|
||||
},
|
||||
"Operation kind named as its tool": {
|
||||
Msg: "update_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_text on the block holding the text.",
|
||||
Result: "update_block_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_block_text on the block holding the text.",
|
||||
},
|
||||
"Unknown message passes through": {
|
||||
Msg: "cannot move a block relative to itself: a",
|
||||
Result: "cannot move a block relative to itself: a",
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, OpError{Message: c.Msg}.describe())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_JoinOpErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := JoinOpErrors([]OpError{
|
||||
{Index: 0, Message: "block_uid not found: a"},
|
||||
{Index: 1, Message: "something else"},
|
||||
})
|
||||
|
||||
// each message is rewritten for the model and the index is left out,
|
||||
// since a tool ships one operation.
|
||||
assert.Equal(t, "no block with uid a in this document; call get_document for the current uids; something else", got)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/errutil"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/strutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
|
|
@ -259,7 +260,7 @@ func (insertBlock) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameInsertBlock, err)
|
||||
}
|
||||
|
||||
return "Updating " + docLabel(doc), nil
|
||||
return "Updating " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary describes the insertion the model wants to make.
|
||||
|
|
@ -275,17 +276,17 @@ func (insertBlock) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameInsertBlock, err)
|
||||
}
|
||||
|
||||
kind := blockKindLabel(in.Block.Type)
|
||||
kind := in.Block.Type.Label()
|
||||
|
||||
var summary string
|
||||
|
||||
switch in.Position {
|
||||
case positionStart:
|
||||
summary = fmt.Sprintf("Prepend %s to %s", kind, docLabel(doc))
|
||||
summary = fmt.Sprintf("Prepend %s to %s", kind, doc.Title())
|
||||
case positionEnd:
|
||||
summary = fmt.Sprintf("Append %s to %s", kind, docLabel(doc))
|
||||
summary = fmt.Sprintf("Append %s to %s", kind, doc.Title())
|
||||
default:
|
||||
summary = fmt.Sprintf("Insert %s %s a block in %s", kind, in.Position, docLabel(doc))
|
||||
summary = fmt.Sprintf("Insert %s %s a block in %s", kind, in.Position, doc.Title())
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
|
|
@ -423,7 +424,7 @@ func (replaceBlock) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameReplaceBlock, err)
|
||||
}
|
||||
|
||||
return "Updating " + docLabel(doc), nil
|
||||
return "Updating " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary describes the replacement the model wants to make.
|
||||
|
|
@ -443,7 +444,7 @@ func (replaceBlock) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
Tool: NameReplaceBlock,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: fmt.Sprintf("Replace a block in %s with %s", doc.DocumentName, blockKindLabel(in.Block.Type)),
|
||||
Summary: fmt.Sprintf("Replace a block in %s with %s", doc.DocumentName, in.Block.Type.Label()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -547,7 +548,7 @@ func (updateBlockText) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameUpdateBlockText, err)
|
||||
}
|
||||
|
||||
return "Updating " + docLabel(doc), nil
|
||||
return "Updating " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary previews the text the model wants to write.
|
||||
|
|
@ -558,7 +559,7 @@ func (updateBlockText) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
preview := textPreview(in.Text, _maxPreviewLen)
|
||||
preview := strutil.Preview(in.Text, _maxPreviewLen)
|
||||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
|
|
@ -567,7 +568,7 @@ func (updateBlockText) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
|
||||
summary := fmt.Sprintf("Update a block in %s: %q", doc.DocumentName, preview)
|
||||
if preview == "" {
|
||||
summary = "Update text of a block in " + docLabel(doc)
|
||||
summary = "Update text of a block in " + doc.Title()
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
|
|
@ -678,7 +679,7 @@ func (updateBlockAttrs) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameUpdateBlockAttrs, err)
|
||||
}
|
||||
|
||||
return "Updating " + docLabel(doc), nil
|
||||
return "Updating " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary names the attributes the model wants to set.
|
||||
|
|
@ -702,7 +703,7 @@ func (updateBlockAttrs) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
Tool: NameUpdateBlockAttrs,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: fmt.Sprintf("Update block %s in %s", strings.Join(keys, ", "), docLabel(doc)),
|
||||
Summary: fmt.Sprintf("Update block %s in %s", strings.Join(keys, ", "), doc.Title()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -810,7 +811,7 @@ func (deleteBlock) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameDeleteBlock, err)
|
||||
}
|
||||
|
||||
return "Updating " + docLabel(doc), nil
|
||||
return "Updating " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary describes the deletion the model wants to make.
|
||||
|
|
@ -830,7 +831,7 @@ func (deleteBlock) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
Tool: NameDeleteBlock,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: "Delete a block in " + docLabel(doc),
|
||||
Summary: "Delete a block in " + doc.Title(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -945,7 +946,7 @@ func (moveBlock) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameMoveBlock, err)
|
||||
}
|
||||
|
||||
return "Updating " + docLabel(doc), nil
|
||||
return "Updating " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary describes the move the model wants to make.
|
||||
|
|
@ -965,7 +966,7 @@ func (moveBlock) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
Tool: NameMoveBlock,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: fmt.Sprintf("Move a block %s another block in %s", in.Position, docLabel(doc)),
|
||||
Summary: fmt.Sprintf("Move a block %s another block in %s", in.Position, doc.Title()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package tools
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
|
|
@ -17,23 +16,14 @@ import (
|
|||
// survives the checkpoint written when the turn is interrupted.
|
||||
const SessionKeyAutoApprove = "oxynote_assistant_auto_approve"
|
||||
|
||||
// _registerOnce guards the one-time registration of the types that
|
||||
// cross an interrupt. Registering twice panics.
|
||||
var _registerOnce sync.Once
|
||||
|
||||
// registerConfirmTypes teaches the checkpoint serialiser about the
|
||||
// values a paused write carries. A turn parked on a confirmation is
|
||||
// written to Redis, and an unregistered type fails that write, which
|
||||
// would surface as a failed turn rather than a prompt.
|
||||
//
|
||||
// The names are the identities those checkpoints are written under.
|
||||
// Renaming one strands every turn already parked in Redis, so a rename
|
||||
// is only free while nothing is deployed.
|
||||
func registerConfirmTypes() {
|
||||
_registerOnce.Do(func() {
|
||||
schema.RegisterName[confirmState]("oxynote_assistant_confirm_state")
|
||||
schema.RegisterName[ActionSummary]("oxynote_assistant_action_summary")
|
||||
})
|
||||
// the checkpoint is gob-encoded, and both values sit behind interface
|
||||
// fields in it: confirmState as the interrupt state, ActionSummary as
|
||||
// the interrupt info. gob needs a name for a concrete type it meets
|
||||
// through an interface, and a checkpoint already in Redis carries the
|
||||
// name it was written with, so a rename fails to decode it.
|
||||
func init() {
|
||||
schema.RegisterName[confirmState]("oxynote_assistant_confirm_state")
|
||||
schema.RegisterName[ActionSummary]("oxynote_assistant_action_summary")
|
||||
}
|
||||
|
||||
// ActionSummary is the human-readable description of a pending write op
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/internal/tag"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
|
|
@ -117,7 +118,7 @@ type getDocument struct {
|
|||
func (getDocument) Info() Info {
|
||||
return Info{
|
||||
Name: NameGetDocument,
|
||||
Description: "Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, name, color}], followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.",
|
||||
Description: "Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, tagName, color}], followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write."},
|
||||
|
|
@ -139,7 +140,7 @@ func (getDocument) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameGetDocument, err)
|
||||
}
|
||||
|
||||
return "Reading " + docLabel(doc), nil
|
||||
return "Reading " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Execute fetches the branch and summarises its content. The document
|
||||
|
|
@ -175,10 +176,14 @@ func (getDocument) Execute(inp Input) (string, error) {
|
|||
UpdatedAt: doc.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
Branch: branchInfo{ID: doc.BranchID, Name: doc.BranchName, Protected: doc.Protected, Default: doc.Default},
|
||||
Branches: make([]branchInfo, 0, len(branches)),
|
||||
Tags: tagInfos(tags),
|
||||
Tags: make([]tag.Summary, 0, len(tags)),
|
||||
Blocks: walkDocForAssistant(doc.Content.Content),
|
||||
}
|
||||
|
||||
for _, tg := range tags {
|
||||
out.Tags = append(out.Tags, tg.Summary())
|
||||
}
|
||||
|
||||
for _, b := range branches {
|
||||
out.Branches = append(out.Branches, branchInfo{
|
||||
ID: b.BranchID,
|
||||
|
|
@ -219,7 +224,7 @@ type documentResult struct {
|
|||
Branches []branchInfo `json:"branches"`
|
||||
|
||||
// Tags is every tag the branch read carries.
|
||||
Tags []tagInfo `json:"tags"`
|
||||
Tags []tag.Summary `json:"tags"`
|
||||
|
||||
// Blocks is the branch's content, one row per block.
|
||||
Blocks []docSummaryEntry `json:"blocks"`
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ func Test_getDocument_Execute(t *testing.T) {
|
|||
DB: tagged,
|
||||
Args: `{` + targetArgs(_stubBranchID) + `}`,
|
||||
Contains: []string{
|
||||
`"tags":[{"id":"` + _testTagID.String() + `","name":"Production","color":"#22c55e"}]`,
|
||||
`"tags":[{"id":"` + _testTagID.String() + `","tagName":"Production","color":"#22c55e","hidden":false}]`,
|
||||
},
|
||||
},
|
||||
"Root document omits the parent": {
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ func (createHook) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameCreateHook, err)
|
||||
}
|
||||
|
||||
return "Adding a hook to " + docLabel(doc), nil
|
||||
return "Adding a hook to " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary names the hook type and where it goes.
|
||||
|
|
@ -777,7 +777,7 @@ func describeHook(inp DescribeInput, name Name, ref hookRefArgs) (*document.Docu
|
|||
// hookLabel names a hook by its type, the document it is on and, when
|
||||
// anchored, its block, in the words the confirm card uses.
|
||||
func hookLabel(tp hook.Type, blockUID null.String, doc *document.Document) string {
|
||||
label := tp.HumanizedString() + " hook on " + docLabel(doc)
|
||||
label := tp.HumanizedString() + " hook on " + doc.Title()
|
||||
|
||||
if blockUID.Valid {
|
||||
label += ", block " + blockUID.String
|
||||
|
|
|
|||
|
|
@ -675,7 +675,7 @@ func (i *input) ApplyEdit(documentID, branchID xid.ID, ops []edit.Operation) err
|
|||
// marks the result isError, and the assistant sees a failure it can
|
||||
// correct rather than a success it will summarise.
|
||||
if res.Applied == 0 && len(res.Errors) > 0 {
|
||||
return fmt.Errorf("applying edit: %s", joinOpErrors(res.Errors))
|
||||
return fmt.Errorf("applying edit: %s", edit.JoinOpErrors(res.Errors))
|
||||
}
|
||||
|
||||
if len(res.Errors) > 0 {
|
||||
|
|
|
|||
|
|
@ -489,8 +489,12 @@ func (assignTag) Info() Info {
|
|||
return Info{
|
||||
Name: NameAssignTag,
|
||||
Description: "Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.",
|
||||
Properties: branchTagProps(),
|
||||
Required: []string{"document_id", "branch_id", "tag_id"},
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."},
|
||||
"tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
|
||||
},
|
||||
Required: []string{"document_id", "branch_id", "tag_id"},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -512,7 +516,7 @@ func (assignTag) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameAssignTag, err)
|
||||
}
|
||||
|
||||
return "Tagging " + docLabel(doc), nil
|
||||
return "Tagging " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary names the tag and the document it goes on.
|
||||
|
|
@ -537,7 +541,7 @@ func (assignTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
Tool: NameAssignTag,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: fmt.Sprintf("Put tag %s on %s", tg.TagName, docLabel(doc)),
|
||||
Summary: fmt.Sprintf("Put tag %s on %s", tg.TagName, doc.Title()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -567,8 +571,12 @@ func (unassignTag) Info() Info {
|
|||
return Info{
|
||||
Name: NameUnassignTag,
|
||||
Description: "Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.",
|
||||
Properties: branchTagProps(),
|
||||
Required: []string{"document_id", "branch_id", "tag_id"},
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."},
|
||||
"tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
|
||||
},
|
||||
Required: []string{"document_id", "branch_id", "tag_id"},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -590,7 +598,7 @@ func (unassignTag) Title(inp DescribeInput) (string, error) {
|
|||
return "", fmt.Errorf("%s: fetch document: %w", NameUnassignTag, err)
|
||||
}
|
||||
|
||||
return "Untagging " + docLabel(doc), nil
|
||||
return "Untagging " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary names the tag and the document it comes off.
|
||||
|
|
@ -615,7 +623,7 @@ func (unassignTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
Tool: NameUnassignTag,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: fmt.Sprintf("Take tag %s off %s", tg.TagName, docLabel(doc)),
|
||||
Summary: fmt.Sprintf("Take tag %s off %s", tg.TagName, doc.Title()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -753,36 +761,3 @@ type movedTagResult struct {
|
|||
// SortIndex is the 0-based position it now has.
|
||||
SortIndex int `json:"sort_index"`
|
||||
}
|
||||
|
||||
// tagInfo describes one tag a branch carries to the model.
|
||||
type tagInfo struct {
|
||||
// ID is the tag id, which is what a tool's tag_id takes.
|
||||
ID xid.ID `json:"id"`
|
||||
|
||||
// Name is the tag's display name.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Color is the tag's colour as a hex triplet.
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// tagInfos converts the tags a branch carries into the model's shape.
|
||||
func tagInfos(tags []tag.Tag) []tagInfo {
|
||||
out := make([]tagInfo, 0, len(tags))
|
||||
|
||||
for _, t := range tags {
|
||||
out = append(out, tagInfo{ID: t.ID, Name: t.TagName, Color: t.Color})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// branchTagProps builds the argument schema assign_tag and unassign_tag
|
||||
// share.
|
||||
func branchTagProps() map[string]any {
|
||||
return map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."},
|
||||
"tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,15 +137,6 @@ func Test_listTags_Execute(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_tagInfos(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, []tagInfo{}, tagInfos(nil))
|
||||
assert.Equal(t, []tagInfo{
|
||||
{ID: _testTagID, Name: _stubTagName, Color: _stubTagColor},
|
||||
}, tagInfos([]tag.Tag{{ID: _testTagID, TagName: _stubTagName, Color: _stubTagColor, OrganizationID: "org"}}))
|
||||
}
|
||||
|
||||
func Test_createTagArgs_Validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -597,16 +588,6 @@ func Test_branchTagArgs_Validate(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func Test_branchTagProps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
props := branchTagProps()
|
||||
|
||||
assert.Contains(t, props, "document_id")
|
||||
assert.Contains(t, props, "branch_id")
|
||||
assert.Contains(t, props, "tag_id")
|
||||
}
|
||||
|
||||
func Test_assignTag_Info(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -100,8 +100,6 @@ type Set struct {
|
|||
|
||||
// New creates a fresh instance of Set.
|
||||
func New(deps *Deps) *Set {
|
||||
registerConfirmTypes()
|
||||
|
||||
all := []Tool{
|
||||
listDocuments{},
|
||||
getDocument{},
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@ package tools
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/strutil"
|
||||
)
|
||||
|
||||
// result is the single place tool result envelopes are serialised;
|
||||
|
|
@ -22,113 +16,7 @@ func result(v any) (string, error) {
|
|||
return string(data), nil
|
||||
}
|
||||
|
||||
// joinOpErrors renders per-operation failures as one message. The
|
||||
// index is left out: a tool sends a single operation, so naming its
|
||||
// position says nothing the reader can act on.
|
||||
func joinOpErrors(errs []edit.OpError) string {
|
||||
msgs := make([]string, 0, len(errs))
|
||||
|
||||
for _, e := range errs {
|
||||
msgs = append(msgs, describeOpError(e.Message))
|
||||
}
|
||||
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// _opKindNames maps the realtime service's operation kinds, which it
|
||||
// names in its errors, to the tools the model knows them as.
|
||||
var _opKindNames = strings.NewReplacer("update_text", "update_block_text")
|
||||
|
||||
// describeOpError rewrites a realtime-service operation error into
|
||||
// what the model can act on: a uid it holds no block for points at
|
||||
// get_document, a reference inside the moved block says what to pick
|
||||
// instead, and an operation kind is named as its tool. A message with
|
||||
// no rewrite passes through as it is.
|
||||
func describeOpError(msg string) string {
|
||||
for _, prefix := range []string{"reference_uid not found: ", "block_uid not found: "} {
|
||||
if uid, ok := strings.CutPrefix(msg, prefix); ok {
|
||||
return fmt.Sprintf("no block with uid %s in this document; call get_document for the current uids", uid)
|
||||
}
|
||||
}
|
||||
|
||||
if uid, ok := strings.CutPrefix(msg, "reference_uid is inside the moved block: "); ok {
|
||||
return fmt.Sprintf("reference block %s sits inside the block being moved; choose a reference outside it", uid)
|
||||
}
|
||||
|
||||
return _opKindNames.Replace(msg)
|
||||
}
|
||||
|
||||
// errRequired reports an argument the tool cannot act without.
|
||||
func errRequired(key string) error {
|
||||
return fmt.Errorf("%s is required", key)
|
||||
}
|
||||
|
||||
// blockKindLabel turns a canonical block type into a friendlier label
|
||||
// for the confirm UI. Unknown types fall through verbatim.
|
||||
func blockKindLabel(kind block.Type) string {
|
||||
switch kind {
|
||||
case block.BlockParagraph:
|
||||
return "a paragraph"
|
||||
case block.BlockHeading:
|
||||
return "a heading"
|
||||
case block.BlockBlockquote:
|
||||
return "a blockquote"
|
||||
case block.BlockBulletList:
|
||||
return "a bullet list"
|
||||
case block.BlockOrderedList:
|
||||
return "an ordered list"
|
||||
case block.BlockTaskList:
|
||||
return "a task list"
|
||||
case block.BlockCallout:
|
||||
return "a callout"
|
||||
case block.BlockCode:
|
||||
return "a code block"
|
||||
case block.BlockTitledCode:
|
||||
return "a titled code block"
|
||||
case block.BlockMermaid:
|
||||
return "a mermaid diagram"
|
||||
case block.BlockHorizontalRule:
|
||||
return "a divider"
|
||||
case block.BlockImage:
|
||||
return "an image"
|
||||
case block.BlockFile:
|
||||
return "a file attachment"
|
||||
case block.BlockFigma:
|
||||
return "a figma embed"
|
||||
case block.BlockMetric:
|
||||
return "a metric"
|
||||
case block.BlockMetricGrid:
|
||||
return "a metric grid"
|
||||
case block.BlockSplitDoc:
|
||||
return "a split documentation block"
|
||||
case block.BlockParamList:
|
||||
return "a parameter list"
|
||||
}
|
||||
|
||||
if kind == "" {
|
||||
return "a block"
|
||||
}
|
||||
|
||||
return "a " + string(kind) + " block"
|
||||
}
|
||||
|
||||
// textPreview returns at most maxLen runes of s, collapsed to a single
|
||||
// line. Used for surfacing short snippets of an upcoming text edit in
|
||||
// the confirm UI.
|
||||
func textPreview(s string, maxLen int) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
return strutil.Ellipsize(s, maxLen)
|
||||
}
|
||||
|
||||
// docLabel names a document the way a status line or a confirm card
|
||||
// shows it: by display name, with the branch appended when it is not the
|
||||
// document's default one, so a write aimed at a branch reads as such.
|
||||
func docLabel(doc *document.Document) string {
|
||||
if doc.Default {
|
||||
return doc.DocumentName
|
||||
}
|
||||
|
||||
return doc.DocumentName + " on branch " + doc.BranchName
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ package tools
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -27,111 +25,3 @@ func Test_errRequired(t *testing.T) {
|
|||
|
||||
assert.EqualError(t, errRequired("document_id"), "document_id is required")
|
||||
}
|
||||
|
||||
func Test_blockKindLabel(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Kind block.Type
|
||||
Result string
|
||||
}{
|
||||
"Paragraph": {Kind: "paragraph", Result: "a paragraph"},
|
||||
"Heading": {Kind: "heading", Result: "a heading"},
|
||||
"Blockquote": {Kind: "blockquote", Result: "a blockquote"},
|
||||
"Bullet list": {Kind: "bullet_list", Result: "a bullet list"},
|
||||
"Ordered list": {Kind: "ordered_list", Result: "an ordered list"},
|
||||
"Task list": {Kind: "task_list", Result: "a task list"},
|
||||
"Callout": {Kind: "callout", Result: "a callout"},
|
||||
"Code": {Kind: "code", Result: "a code block"},
|
||||
"Titled code": {Kind: "titled_code", Result: "a titled code block"},
|
||||
"Mermaid": {Kind: "mermaid", Result: "a mermaid diagram"},
|
||||
"Horizontal rule": {Kind: "horizontal_rule", Result: "a divider"},
|
||||
"Image": {Kind: "image", Result: "an image"},
|
||||
"File": {Kind: "file", Result: "a file attachment"},
|
||||
"Figma": {Kind: "figma", Result: "a figma embed"},
|
||||
"Metric": {Kind: "metric", Result: "a metric"},
|
||||
"Metric grid": {Kind: "metric_grid", Result: "a metric grid"},
|
||||
"Split doc": {Kind: "split_doc", Result: "a split documentation block"},
|
||||
"Param list": {Kind: "split_doc_param_list", Result: "a parameter list"},
|
||||
"Empty": {Kind: "", Result: "a block"},
|
||||
"Unknown": {Kind: "wibble", Result: "a wibble block"},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, blockKindLabel(c.Kind))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_textPreview(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Input string
|
||||
MaxLen int
|
||||
Result string
|
||||
}{
|
||||
"Newlines collapse": {Input: "a\nb\nc", MaxLen: 10, Result: "a b c"},
|
||||
"Whitespace trimmed": {Input: " x ", MaxLen: 10, Result: "x"},
|
||||
"Long text elided": {Input: "abcdef", MaxLen: 3, Result: "abc…"},
|
||||
"Empty input": {Input: "", MaxLen: 3, Result: ""},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, textPreview(c.Input, c.MaxLen))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_joinOpErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := joinOpErrors([]edit.OpError{
|
||||
{Index: 0, Message: "block_uid not found: a"},
|
||||
{Index: 1, Message: "something else"},
|
||||
})
|
||||
|
||||
// each message is rewritten for the model and the index is left out,
|
||||
// since a tool ships one operation.
|
||||
assert.Equal(t, "no block with uid a in this document; call get_document for the current uids; something else", got)
|
||||
}
|
||||
|
||||
func Test_describeOpError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Msg string
|
||||
Result string
|
||||
}{
|
||||
"Reference uid not found": {
|
||||
Msg: "reference_uid not found: r1",
|
||||
Result: "no block with uid r1 in this document; call get_document for the current uids",
|
||||
},
|
||||
"Block uid not found": {
|
||||
Msg: "block_uid not found: b1",
|
||||
Result: "no block with uid b1 in this document; call get_document for the current uids",
|
||||
},
|
||||
"Reference inside the moved block": {
|
||||
Msg: "reference_uid is inside the moved block: r1",
|
||||
Result: "reference block r1 sits inside the block being moved; choose a reference outside it",
|
||||
},
|
||||
"Operation kind named as its tool": {
|
||||
Msg: "update_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_text on the block holding the text.",
|
||||
Result: "update_block_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_block_text on the block holding the text.",
|
||||
},
|
||||
"Unknown message passes through": {
|
||||
Msg: "cannot move a block relative to itself: a",
|
||||
Result: "cannot move a block relative to itself: a",
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, describeOpError(c.Msg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,6 +266,17 @@ func (d Document) ApplyBranchUpdate(name null.String, protected null.Bool, updat
|
|||
return nd
|
||||
}
|
||||
|
||||
// Title names the document the way a status line or a confirm card
|
||||
// shows it: by display name, with the branch appended when it is not the
|
||||
// default one, so a write aimed at a branch reads as such.
|
||||
func (d Document) Title() string {
|
||||
if d.Default {
|
||||
return d.DocumentName
|
||||
}
|
||||
|
||||
return d.DocumentName + " on branch " + d.BranchName
|
||||
}
|
||||
|
||||
// HistoryEntry returns a history entry for the current state of the document.
|
||||
// Entries aggregate per branch and per time bucket: the branch is part of the
|
||||
// ID so that two branches of one document edited within the same bucket keep
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ func NewTag(inp CreateInput, organizationID, userID string) Tag {
|
|||
}
|
||||
}
|
||||
|
||||
// Summary returns the tag on its own: the identity, name and colour,
|
||||
// without the per-user hidden flag or the documents carrying it.
|
||||
func (t Tag) Summary() Summary {
|
||||
return Summary{ID: t.ID, TagName: t.TagName, Color: t.Color}
|
||||
}
|
||||
|
||||
// UpdateInput is the input for renaming or recolouring a tag. A field left
|
||||
// unset keeps its current value.
|
||||
type UpdateInput struct {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,15 @@ func Test_NewTag(t *testing.T) {
|
|||
assert.Zero(t, tg.SortIndex)
|
||||
}
|
||||
|
||||
func Test_Tag_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id := xid.New()
|
||||
tg := Tag{ID: id, OrganizationID: "org1", TagName: "Production", Color: "#22c55e", SortIndex: 3}
|
||||
|
||||
assert.Equal(t, Summary{ID: id, TagName: "Production", Color: "#22c55e"}, tg.Summary())
|
||||
}
|
||||
|
||||
func Test_Summaries_Swap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// codebase.
|
||||
package strutil
|
||||
|
||||
import "strings"
|
||||
|
||||
// Ellipsize returns at most maxLen runes of s, appending a single
|
||||
// ellipsis rune when the input was longer. A non-positive maxLen
|
||||
// returns s unchanged.
|
||||
|
|
@ -17,3 +19,12 @@ func Ellipsize(s string, maxLen int) string {
|
|||
|
||||
return string(runes[:maxLen]) + "…"
|
||||
}
|
||||
|
||||
// Preview returns at most maxLen runes of s, collapsed to a single line
|
||||
// and trimmed, elided the way Ellipsize does.
|
||||
func Preview(s string, maxLen int) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
return Ellipsize(s, maxLen)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,3 +34,24 @@ func Test_Ellipsize(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Preview(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Input string
|
||||
MaxLen int
|
||||
Result string
|
||||
}{
|
||||
"Newlines collapse": {Input: "a\nb\nc", MaxLen: 10, Result: "a b c"},
|
||||
"Whitespace trimmed": {Input: " x ", MaxLen: 10, Result: "x"},
|
||||
"Long text elided": {Input: "abcdef", MaxLen: 3, Result: "abc…"},
|
||||
"Empty input": {Input: "", MaxLen: 3, Result: ""},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, Preview(c.Input, c.MaxLen))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user