mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(studio): confirm a change with the owner's own text
The proposal card lets the owner correct the text before confirming.
POST /api/chats/changes/{id}/confirm now accepts an optional JSON body
{"content": "..."}. The text has the same size limit and text check as a
proposal, the file and the audit hash hold what was written, and a
refused text leaves the proposal waiting.
Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
7 files changed
+74
-9
No files matched your search
@@ -1,6 +1,9 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -67,7 +70,7 @@ func (h *Handlers) GetChange(c echo.Context) error {
|
||||
|
||||
// ConfirmChange writes a proposed change.
|
||||
// @Summary Confirm a proposed change.
|
||||
// @Description Writes the change to your files as one change set that Undo can take back. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.
|
||||
// @Description Writes the change to your files as one change set that Undo can take back. A JSON body {"content": "..."} writes your own text in place of the proposed one; it has the same size limit. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.
|
||||
// @Tags chats
|
||||
// @Param id path string true "the proposal"
|
||||
// @Success 200 {object} contract.ChangeProposal "The applied proposal"
|
||||
@@ -75,7 +78,21 @@ func (h *Handlers) GetChange(c echo.Context) error {
|
||||
// @Failure 409 {object} schema.ErrorResponse "Stale, expired or answered"
|
||||
// @Router /api/chats/changes/{id}/confirm [post]
|
||||
func (h *Handlers) ConfirmChange(c echo.Context) error {
|
||||
p, err := h.svc.Changes().Confirm(c.Request().Context(), userID(c), c.Param("id"))
|
||||
var body struct {
|
||||
Content *string `json:"content"`
|
||||
}
|
||||
if c.Request().ContentLength != 0 {
|
||||
if err := json.NewDecoder(io.LimitReader(c.Request().Body, 2*contract.MaxChangeBytes)).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
return fail(c, http.StatusBadRequest, typeInvalid, "the body must be JSON")
|
||||
}
|
||||
}
|
||||
var p contract.ChangeProposal
|
||||
var err error
|
||||
if body.Content != nil {
|
||||
p, err = h.svc.Changes().ConfirmEdited(c.Request().Context(), userID(c), c.Param("id"), *body.Content)
|
||||
} else {
|
||||
p, err = h.svc.Changes().Confirm(c.Request().Context(), userID(c), c.Param("id"))
|
||||
}
|
||||
if err != nil {
|
||||
return failChange(c, err)
|
||||
}
|
||||
@@ -134,7 +151,6 @@ func (h *Handlers) ChangeAudit(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, evs)
|
||||
}
|
||||
|
||||
|
||||
// ListContextFiles lists the notes the host puts in the prompt of a chat.
|
||||
// @Summary List your context files.
|
||||
// @Description The short index files that the host loads into every chat (your memory, an agent's notes, a conversation's notes), with the topic files next to them, the agent profiles and the caps that keep a turn small. It only reads; change a file on the Files page or confirm a proposal.
|
||||
|
||||
@@ -60,6 +60,11 @@ var _ = Describe("Chats proposed changes", func() {
|
||||
Expect(s.do("alice", http.MethodGet, "/api/chats/files/content?path=/Memory/a.md", nil).Code).To(Equal(http.StatusNotFound))
|
||||
Expect(s.do("alice", http.MethodPost, url+"/undo", nil).Code).To(Equal(http.StatusConflict))
|
||||
|
||||
c := propose("c.md")
|
||||
edited := s.do("alice", http.MethodPost, "/api/chats/changes/"+c+"/confirm", map[string]string{"content": "- mine\n"})
|
||||
Expect(edited.Code).To(Equal(http.StatusOK), edited.Body.String())
|
||||
Expect(s.do("alice", http.MethodGet, "/api/chats/files/content?path=/Memory/c.md", nil).Body.String()).To(Equal("- mine\n"))
|
||||
|
||||
b := propose("b.md")
|
||||
dis := s.do("alice", http.MethodPost, "/api/chats/changes/"+b+"/dismiss", nil)
|
||||
Expect(dis.Code).To(Equal(http.StatusOK), dis.Body.String())
|
||||
@@ -70,7 +75,7 @@ var _ = Describe("Chats proposed changes", func() {
|
||||
rec := s.do("alice", http.MethodGet, "/api/chats/changes/audit", nil)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &audit)).To(Succeed())
|
||||
Expect(audit).To(HaveLen(5), "propose, confirm, revert, propose, dismiss: %v", audit)
|
||||
Expect(audit).To(HaveLen(7), "propose, confirm, revert, propose, confirm, propose, dismiss: %v", audit)
|
||||
Expect(rec.Body.String()).NotTo(ContainSubstring("fact"))
|
||||
var none []map[string]any
|
||||
Expect(json.Unmarshal(s.do("bob", http.MethodGet, "/api/chats/changes", nil).Body.Bytes(), &none)).To(Succeed())
|
||||
|
||||
@@ -216,6 +216,18 @@ func notPending(p contract.ChangeProposal) error {
|
||||
|
||||
// Confirm writes a pending proposal as the owner, in one change set.
|
||||
func (c *Changes) Confirm(ctx context.Context, user, id string) (contract.ChangeProposal, error) {
|
||||
return c.confirm(ctx, user, id, nil)
|
||||
}
|
||||
|
||||
// ConfirmEdited writes the pending proposal with the owner's own text in place
|
||||
// of the model's. The text has the same limits as a proposal. The stored
|
||||
// proposal keeps what the model asked for; the file and the audit hash hold
|
||||
// what was written.
|
||||
func (c *Changes) ConfirmEdited(ctx context.Context, user, id, content string) (contract.ChangeProposal, error) {
|
||||
return c.confirm(ctx, user, id, &content)
|
||||
}
|
||||
|
||||
func (c *Changes) confirm(ctx context.Context, user, id string, edited *string) (contract.ChangeProposal, error) {
|
||||
if err := c.ready(); err != nil {
|
||||
return contract.ChangeProposal{}, err
|
||||
}
|
||||
@@ -231,6 +243,15 @@ func (c *Changes) Confirm(ctx context.Context, user, id string) (contract.Change
|
||||
if !ok {
|
||||
return contract.ChangeProposal{}, fmt.Errorf("%w: this server no longer offers %s changes", ErrBadChange, p.Kind)
|
||||
}
|
||||
if edited != nil {
|
||||
switch {
|
||||
case len(*edited) > kind.Limit():
|
||||
return contract.ChangeProposal{}, fmt.Errorf("%w: the text is %d bytes and a %s change may have %d", ErrBadChange, len(*edited), kind.Name, kind.Limit())
|
||||
case !utf8.ValidString(*edited) || strings.ContainsRune(*edited, 0):
|
||||
return contract.ChangeProposal{}, fmt.Errorf("%w: the text must be text", ErrBadChange)
|
||||
}
|
||||
p.Content = *edited
|
||||
}
|
||||
cur, exists, err := c.s.d.Files.ReadHome(ctx, owner, p.Target, maxTargetRead)
|
||||
if err != nil {
|
||||
return contract.ChangeProposal{}, err
|
||||
|
||||
@@ -226,6 +226,28 @@ var _ = Describe("change proposals", func() {
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("writes the owner's own text when the owner edits before confirming", func() {
|
||||
r := changeShared(nil).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
|
||||
ctx := context.Background()
|
||||
id := create(r.svc, alice)
|
||||
sent(r.svc, alice, id, "remember")
|
||||
idle(r.svc, alice, id)
|
||||
p := onlyProposal(r)
|
||||
_, err := r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, strings.Repeat("x", 16<<10+1))
|
||||
Expect(err).To(MatchError(studio.ErrBadChange))
|
||||
got, _ := r.svc.Changes().Get(ctx, alice, p.ID)
|
||||
Expect(got.Status).To(Equal(contract.ProposalPending), "a refused edit leaves the proposal waiting")
|
||||
done, err := r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, "- home: Milan\n")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(done.Status).To(Equal(contract.ProposalApplied))
|
||||
text, _ := readHome(r, "Memory/index.md")
|
||||
Expect(text).To(Equal("- home: Milan\n"))
|
||||
_, err = r.svc.Changes().Revert(ctx, alice, p.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, ok := readHome(r, "Memory/index.md")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("refuses a stale proposal and keeps the newer file", func() {
|
||||
r := changeShared(nil).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
|
||||
ctx := context.Background()
|
||||
|
||||
+1
-1
@@ -4841,7 +4841,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/chats/changes/{id}/confirm": {
|
||||
"post": {
|
||||
"description": "Writes the change to your files as one change set that Undo can take back. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.",
|
||||
"description": "Writes the change to your files as one change set that Undo can take back. A JSON body {\"content\": \"...\"} writes your own text in place of the proposed one; it has the same size limit. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
|
||||
@@ -4838,7 +4838,7 @@
|
||||
},
|
||||
"/api/chats/changes/{id}/confirm": {
|
||||
"post": {
|
||||
"description": "Writes the change to your files as one change set that Undo can take back. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.",
|
||||
"description": "Writes the change to your files as one change set that Undo can take back. A JSON body {\"content\": \"...\"} writes your own text in place of the proposed one; it has the same size limit. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
|
||||
@@ -14464,9 +14464,10 @@ paths:
|
||||
- chats
|
||||
/api/chats/changes/{id}/confirm:
|
||||
post:
|
||||
description: Writes the change to your files as one change set that Undo can
|
||||
take back. It is refused with 409 when the file changed since the proposal,
|
||||
or when the proposal expired or was answered.
|
||||
description: 'Writes the change to your files as one change set that Undo can
|
||||
take back. A JSON body {"content": "..."} writes your own text in place of
|
||||
the proposed one; it has the same size limit. It is refused with 409 when
|
||||
the file changed since the proposal, or when the proposal expired or was answered.'
|
||||
parameters:
|
||||
- description: the proposal
|
||||
in: path
|
||||
|
||||
Reference in new issue
Block a user