Delete and rename conversations
This commit is contained in:
@@ -286,6 +286,64 @@ func (q *Queries) SelectConversationsUserId(ctx context.Context, userId string)
|
|||||||
return conversations, nil
|
return conversations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateConversationTitle = `
|
||||||
|
UPDATE conversations
|
||||||
|
SET title = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING created_at, id, title, updated_at, user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
// UpdateConversationTitleParams holds arguments for
|
||||||
|
// UpdateConversationTitle.
|
||||||
|
type UpdateConversationTitleParams struct {
|
||||||
|
Id string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConversationTitle sets the title of one conversation and
|
||||||
|
// returns the updated row.
|
||||||
|
//
|
||||||
|
// The update keeps updated_at. That timestamp is the
|
||||||
|
// conversation's synchronization token for its message set, and a
|
||||||
|
// title is not a message: a rename must not move the conversation
|
||||||
|
// in the picker's ordering, which sorts by updated_at.
|
||||||
|
//
|
||||||
|
// A conversation that is missing returns sql.ErrNoRows.
|
||||||
|
func (q *Queries) UpdateConversationTitle(
|
||||||
|
ctx context.Context,
|
||||||
|
arg UpdateConversationTitleParams,
|
||||||
|
) (*Conversation, error) {
|
||||||
|
row := q.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
updateConversationTitle,
|
||||||
|
arg.Title,
|
||||||
|
arg.Id,
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
c Conversation
|
||||||
|
createdAt string
|
||||||
|
updatedAt string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if err := row.Scan(
|
||||||
|
&createdAt,
|
||||||
|
&c.Id,
|
||||||
|
&c.Title,
|
||||||
|
&updatedAt,
|
||||||
|
&c.UserId,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("UpdateConversationTitle: %w", err)
|
||||||
|
}
|
||||||
|
if c.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("UpdateConversationTitle: parse created_at: %w", err)
|
||||||
|
}
|
||||||
|
if c.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("UpdateConversationTitle: parse updated_at: %w", err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
const deleteConversationId = `
|
const deleteConversationId = `
|
||||||
DELETE FROM conversations
|
DELETE FROM conversations
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json/jsontext"
|
"encoding/json/jsontext"
|
||||||
"encoding/json/v2"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -134,6 +135,46 @@ func testConversation(t *testing.T, ctx context.Context, db *SQLite) {
|
|||||||
if len(conversations) != 1 {
|
if len(conversations) != 1 {
|
||||||
t.Fatalf("expected 1 conversation, got %d", len(conversations))
|
t.Fatalf("expected 1 conversation, got %d", len(conversations))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename the conversation. Insert a message first: the insert
|
||||||
|
// trigger moves updated_at, so the rename can show that it
|
||||||
|
// keeps the timestamp.
|
||||||
|
if _, err := qw.InsertMessage(ctx, InsertMessageParams{
|
||||||
|
ConversationId: c.Id,
|
||||||
|
Sequence: 1,
|
||||||
|
Role: MessageRoleUser,
|
||||||
|
Content: jsontext.Value(`{"role":"user","content":"Hello"}`),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertMessage: %v", err)
|
||||||
|
}
|
||||||
|
before, err := qr.SelectConversationId(ctx, c.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectConversationId: %v", err)
|
||||||
|
}
|
||||||
|
renamed, err := qw.UpdateConversationTitle(ctx, UpdateConversationTitleParams{
|
||||||
|
Id: c.Id,
|
||||||
|
Title: "renamed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateConversationTitle: %v", err)
|
||||||
|
}
|
||||||
|
if renamed.Title == nil || *renamed.Title != "renamed" {
|
||||||
|
t.Fatalf("Title = %v, want renamed", renamed.Title)
|
||||||
|
}
|
||||||
|
if !renamed.UpdatedAt.Equal(before.UpdatedAt) {
|
||||||
|
t.Fatalf(
|
||||||
|
"UpdatedAt = %v, want %v: a rename must not move updated_at",
|
||||||
|
renamed.UpdatedAt, before.UpdatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rename of a missing conversation reports no rows.
|
||||||
|
if _, err := qw.UpdateConversationTitle(ctx, UpdateConversationTitleParams{
|
||||||
|
Id: "missing",
|
||||||
|
Title: "renamed",
|
||||||
|
}); !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
t.Fatalf("UpdateConversationTitle(missing) = %v, want sql.ErrNoRows", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMessaging(t *testing.T, ctx context.Context, db *SQLite) {
|
func testMessaging(t *testing.T, ctx context.Context, db *SQLite) {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (svc *Service) listConversations(w http.ResponseWriter, r *http.Request) {
|
func (svc *Service) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -125,6 +129,103 @@ func (svc *Service) getConversationHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxTitleLength is the most characters, counted as Unicode code
|
||||||
|
// points, that a conversation title may hold. It keeps a title
|
||||||
|
// short enough for the picker and small in storage.
|
||||||
|
const maxTitleLength = 256
|
||||||
|
|
||||||
|
// updateConversation renames one conversation. The handler trims
|
||||||
|
// the title, then rejects an empty title or a title longer than
|
||||||
|
// maxTitleLength with 400. A conversation that does not exist, or
|
||||||
|
// belongs to another user, returns 404. The rename is allowed while
|
||||||
|
// inference runs: a title is not a message, and a concurrent
|
||||||
|
// inference does not read or write it.
|
||||||
|
func (svc *Service) updateConversation(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
ctx = r.Context()
|
||||||
|
log = ctx.Value(logKey).(*slog.Logger)
|
||||||
|
id = r.PathValue("id")
|
||||||
|
req struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
|
||||||
|
if err := json.UnmarshalRead(r.Body, &req); err != nil {
|
||||||
|
log.ErrorContext(
|
||||||
|
ctx, "failed to decode request",
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Title = strings.TrimSpace(req.Title)
|
||||||
|
if req.Title == "" {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(req.Title) > maxTitleLength {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the conversation first, so a rename of a conversation
|
||||||
|
// that does not belong to the user reports 404, not a silent
|
||||||
|
// no-op.
|
||||||
|
if _, err := svc.dbr.SelectConversationIdUserId(
|
||||||
|
ctx, id, "default",
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.ErrorContext(
|
||||||
|
ctx, "failed to load conversation",
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
http.StatusText(http.StatusInternalServerError),
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := svc.dbw.UpdateConversationTitle(
|
||||||
|
ctx,
|
||||||
|
db.UpdateConversationTitleParams{
|
||||||
|
Id: id,
|
||||||
|
Title: req.Title,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// The conversation was deleted after the load above.
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.ErrorContext(
|
||||||
|
ctx, "failed to rename conversation",
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
http.StatusText(http.StatusInternalServerError),
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.MarshalWrite(w, saved); err != nil {
|
||||||
|
log.ErrorContext(
|
||||||
|
ctx, "failed to encode conversation response",
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *Service) deleteConversation(w http.ResponseWriter, r *http.Request) {
|
func (svc *Service) deleteConversation(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
ctx = r.Context()
|
ctx = r.Context()
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ func New(
|
|||||||
svc.mux.HandleFunc("GET /conversations", svc.listConversations)
|
svc.mux.HandleFunc("GET /conversations", svc.listConversations)
|
||||||
svc.mux.HandleFunc("POST /conversations", svc.createConversation)
|
svc.mux.HandleFunc("POST /conversations", svc.createConversation)
|
||||||
svc.mux.HandleFunc("GET /conversations/{id}", svc.getConversationHandler)
|
svc.mux.HandleFunc("GET /conversations/{id}", svc.getConversationHandler)
|
||||||
|
svc.mux.HandleFunc(
|
||||||
|
"PATCH /conversations/{id}", svc.updateConversation,
|
||||||
|
)
|
||||||
svc.mux.HandleFunc("DELETE /conversations/{id}", svc.deleteConversation)
|
svc.mux.HandleFunc("DELETE /conversations/{id}", svc.deleteConversation)
|
||||||
|
|
||||||
svc.mux.HandleFunc(
|
svc.mux.HandleFunc(
|
||||||
|
|||||||
@@ -135,6 +135,53 @@ func TestConversationHandlers(t *testing.T) {
|
|||||||
t.Fatalf("get status = %d, want 200", w.Code)
|
t.Fatalf("get status = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename the conversation, then try renames the server must
|
||||||
|
// reject.
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
svc.ServeHTTP(w, httptest.NewRequest(
|
||||||
|
http.MethodPatch, "/conversations/"+created.Id,
|
||||||
|
strings.NewReader(`{"title":"Renamed"}`),
|
||||||
|
))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("rename status = %d, want 200: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var renamed db.Conversation
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &renamed); err != nil {
|
||||||
|
t.Fatalf("Unmarshal renamed: %v", err)
|
||||||
|
}
|
||||||
|
if renamed.Title == nil || *renamed.Title != "Renamed" {
|
||||||
|
t.Fatalf("Title = %v, want Renamed", renamed.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
svc.ServeHTTP(w, httptest.NewRequest(
|
||||||
|
http.MethodPatch, "/conversations/missing",
|
||||||
|
strings.NewReader(`{"title":"Renamed"}`),
|
||||||
|
))
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("rename missing status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
svc.ServeHTTP(w, httptest.NewRequest(
|
||||||
|
http.MethodPatch, "/conversations/"+created.Id,
|
||||||
|
strings.NewReader(`{"title":" "}`),
|
||||||
|
))
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("rename blank status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
svc.ServeHTTP(w, httptest.NewRequest(
|
||||||
|
http.MethodPatch, "/conversations/"+created.Id,
|
||||||
|
strings.NewReader(fmt.Sprintf(
|
||||||
|
`{"title":%q}`, strings.Repeat("x", 257),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("rename long status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
svc.ServeHTTP(w, httptest.NewRequest(
|
svc.ServeHTTP(w, httptest.NewRequest(
|
||||||
http.MethodDelete, "/conversations/"+created.Id, nil,
|
http.MethodDelete, "/conversations/"+created.Id, nil,
|
||||||
|
|||||||
@@ -1029,11 +1029,34 @@ body {
|
|||||||
padding: var(--s-1) var(--s1);
|
padding: var(--s-1) var(--s1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Each row is a container with two parts: the select button that
|
||||||
|
holds the title and the dates, and the small action buttons
|
||||||
|
(rename, delete) on the right. A row is not one button, because
|
||||||
|
a button may not hold other buttons. */
|
||||||
.conversations__item {
|
.conversations__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.conversations__item:hover {
|
||||||
|
background: var(--color-gray1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item--current {
|
||||||
|
background: var(--color-gray1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
padding: var(--s-1);
|
padding: var(--s-1);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -1043,19 +1066,52 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
.conversations__item-main:focus-visible {
|
||||||
.conversations__item:hover {
|
|
||||||
background: var(--color-gray1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversations__item:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-primary);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversations__item--current {
|
.conversations__item-btn {
|
||||||
background: var(--color-gray1);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.125rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item-btn svg {
|
||||||
|
width: var(--action-icon-size);
|
||||||
|
height: var(--action-icon-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.conversations__item-btn:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item-delete:hover {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pending delete: yellow highlight, press again to confirm */
|
||||||
|
.conversations__item-delete.pending {
|
||||||
|
color: var(--color-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.conversations__item-delete.pending:hover {
|
||||||
|
color: var(--color-yellow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversations__item-title {
|
.conversations__item-title {
|
||||||
@@ -1066,6 +1122,22 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The two dates share one line on desktop screens. Narrow screens
|
||||||
|
stack them, so each date fits without clipping. */
|
||||||
|
.conversations__item-dates {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.conversations__item-dates {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations__item-created,
|
||||||
.conversations__item-updated {
|
.conversations__item-updated {
|
||||||
font-size: var(--s-1);
|
font-size: var(--s-1);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -1198,6 +1270,24 @@ body {
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Single-line input for the rename modal. It shares the error
|
||||||
|
marker class with the textareas; see modal__textarea--error. */
|
||||||
|
.modal__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s-1);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--s0);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: var(--border-width) solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.modal__textarea--error {
|
.modal__textarea--error {
|
||||||
border-color: var(--color-red);
|
border-color: var(--color-red);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ export class Api {
|
|||||||
return this._json('/conversations', { method: 'POST', body });
|
return this._json('/conversations', { method: 'POST', body });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renameConversation sets a new title for a conversation. The
|
||||||
|
* server trims the title and rejects an empty one. The returned
|
||||||
|
* row holds the saved title; the caller uses it to update the
|
||||||
|
* picker.
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} title
|
||||||
|
* @returns {Promise<Object>} the saved conversation row.
|
||||||
|
*/
|
||||||
|
async renameConversation(id, title) {
|
||||||
|
return this._json(`/conversations/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { title },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* deleteConversation removes the conversation, all its messages,
|
* deleteConversation removes the conversation, all its messages,
|
||||||
* and its tool execution records. It also cancels an ongoing
|
* and its tool execution records. It also cancels an ongoing
|
||||||
|
|||||||
@@ -69,9 +69,17 @@ class Odidere {
|
|||||||
onSettingsOpen: () =>
|
onSettingsOpen: () =>
|
||||||
this.ui.setSystemMessageDraft(this.storage.getSystemMessage()),
|
this.ui.setSystemMessageDraft(this.storage.getSystemMessage()),
|
||||||
onSaveSystemMessage: (text) => this.storage.setSystemMessage(text),
|
onSaveSystemMessage: (text) => this.storage.setSystemMessage(text),
|
||||||
onConversationsOpen: () => this.loadConversations(),
|
onConversationsOpen: () => {
|
||||||
onConversationSearch: (query) => this.filterConversations(query),
|
this.conversationQuery = '';
|
||||||
|
this.loadConversations();
|
||||||
|
},
|
||||||
|
onConversationSearch: (query) => {
|
||||||
|
this.conversationQuery = query;
|
||||||
|
this.filterConversations(query);
|
||||||
|
},
|
||||||
onSelectConversation: (id) => this.switchConversation(id),
|
onSelectConversation: (id) => this.switchConversation(id),
|
||||||
|
onRenameConversation: (id) => this.renameConversation(id),
|
||||||
|
onDeleteConversation: (id) => this.deleteConversation(id),
|
||||||
onPushToTalkStart: () => this.handlePushToTalkStart(),
|
onPushToTalkStart: () => this.handlePushToTalkStart(),
|
||||||
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
|
onPushToTalkEnd: () => this.handlePushToTalkEnd(),
|
||||||
onTranscribeStart: () => this.handleTranscribeStart(),
|
onTranscribeStart: () => this.handleTranscribeStart(),
|
||||||
@@ -111,6 +119,9 @@ class Odidere {
|
|||||||
this.conversationId = null;
|
this.conversationId = null;
|
||||||
// The rows the conversation picker shows, fetched on open.
|
// The rows the conversation picker shows, fetched on open.
|
||||||
this.conversations = [];
|
this.conversations = [];
|
||||||
|
// The picker's current search text. A rename or delete keeps
|
||||||
|
// it, so the list re-renders with the same filter.
|
||||||
|
this.conversationQuery = '';
|
||||||
this.messages = new Map();
|
this.messages = new Map();
|
||||||
this.drafts = new Map();
|
this.drafts = new Map();
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
@@ -708,6 +719,16 @@ class Odidere {
|
|||||||
this.ui.renderError(`Failed to delete conversation: ${error.message}`);
|
this.ui.renderError(`Failed to delete conversation: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.clearLocalConversation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clearLocalConversation drops every local trace of the selected
|
||||||
|
* conversation: the cached messages, the drafts, the storage, and
|
||||||
|
* the chat view. It sends no request. reset and the picker's
|
||||||
|
* delete call it once the conversation is gone on the server.
|
||||||
|
*/
|
||||||
|
clearLocalConversation() {
|
||||||
this.messages.clear();
|
this.messages.clear();
|
||||||
this.drafts.clear();
|
this.drafts.clear();
|
||||||
this.storage.clearConversation();
|
this.storage.clearConversation();
|
||||||
@@ -814,6 +835,61 @@ class Odidere {
|
|||||||
this.updateSendEnabled();
|
this.updateSendEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renameConversation asks the user for a new title, saves it, and
|
||||||
|
* re-renders the picker with the current search filter. The saved
|
||||||
|
* row comes from the server, so the list shows exactly what was
|
||||||
|
* stored.
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
async renameConversation(id) {
|
||||||
|
const row = this.conversations.find((c) => c.id === id);
|
||||||
|
if (!row) return;
|
||||||
|
const title = await this.ui.editConversationTitle(row.title ?? '');
|
||||||
|
if (title == null) return;
|
||||||
|
try {
|
||||||
|
const saved = await this.api.renameConversation(id, title);
|
||||||
|
row.title = saved.title;
|
||||||
|
} catch (error) {
|
||||||
|
this.ui.renderError(`Failed to rename conversation: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.filterConversations(this.conversationQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deleteConversation removes one conversation from the server and
|
||||||
|
* from the picker. The two-press confirmation in the UI is the
|
||||||
|
* only guard. Deleting the conversation in view also clears the
|
||||||
|
* local state, like reset does.
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
async deleteConversation(id) {
|
||||||
|
try {
|
||||||
|
await this.api.deleteConversation(id);
|
||||||
|
} catch (error) {
|
||||||
|
this.ui.renderError(`Failed to delete conversation: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.conversations = this.conversations.filter((c) => c.id !== id);
|
||||||
|
if (id === this.conversationId) {
|
||||||
|
// The server-side delete cancels an ongoing inference. Detach
|
||||||
|
// the local streams before the state goes, so the aborted
|
||||||
|
// run's cleanup does not open a new subscription.
|
||||||
|
this.tts.stop();
|
||||||
|
this.conversationId = null;
|
||||||
|
this.syncController?.abort();
|
||||||
|
this.runController?.abort();
|
||||||
|
if (this.currentRun) {
|
||||||
|
try {
|
||||||
|
await this.currentRun;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
this.clearLocalConversation();
|
||||||
|
}
|
||||||
|
this.filterConversations(this.conversationQuery);
|
||||||
|
}
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// SYNC
|
// SYNC
|
||||||
// ====================
|
// ====================
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export class UI {
|
|||||||
this.$tplConversationItem = document.getElementById(
|
this.$tplConversationItem = document.getElementById(
|
||||||
'tpl-conversation-item',
|
'tpl-conversation-item',
|
||||||
);
|
);
|
||||||
|
this.$tplRenameModal = document.getElementById('tpl-rename-modal');
|
||||||
|
|
||||||
// Compose settings modal.
|
// Compose settings modal.
|
||||||
this.$composeSettings = document.getElementById('compose-settings');
|
this.$composeSettings = document.getElementById('compose-settings');
|
||||||
@@ -904,6 +905,10 @@ export class UI {
|
|||||||
$item.dataset.conversationId = conversation.id;
|
$item.dataset.conversationId = conversation.id;
|
||||||
$item.querySelector('.conversations__item-title').textContent =
|
$item.querySelector('.conversations__item-title').textContent =
|
||||||
conversationLabel(conversation);
|
conversationLabel(conversation);
|
||||||
|
$item.querySelector('.conversations__item-created').textContent =
|
||||||
|
`Created ${CONVERSATION_DATE.format(
|
||||||
|
new Date(conversation.created_at),
|
||||||
|
)}`;
|
||||||
$item.querySelector('.conversations__item-updated').textContent =
|
$item.querySelector('.conversations__item-updated').textContent =
|
||||||
`Updated ${CONVERSATION_DATE.format(
|
`Updated ${CONVERSATION_DATE.format(
|
||||||
new Date(conversation.updated_at),
|
new Date(conversation.updated_at),
|
||||||
@@ -951,6 +956,27 @@ export class UI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* editConversationTitle opens the rename modal with the current
|
||||||
|
* title. It resolves with the trimmed new title when saved, and
|
||||||
|
* with null when cancelled. An empty title is not a rename, so
|
||||||
|
* the modal stays open until the user enters one or cancels.
|
||||||
|
* @param {string} title
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
|
editConversationTitle(title) {
|
||||||
|
const $modal =
|
||||||
|
this.$tplRenameModal.content.cloneNode(true).firstElementChild;
|
||||||
|
$modal.querySelector('#rename-input').value = title;
|
||||||
|
return this._openModal($modal, ($modal) => {
|
||||||
|
const value = $modal.querySelector('#rename-input').value.trim();
|
||||||
|
if (value === '') {
|
||||||
|
throw new Error('the title must not be empty');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* editToolCall opens the tool edit modal, which edits the call's
|
* editToolCall opens the tool edit modal, which edits the call's
|
||||||
* name and arguments and, when a result exists, its text. It
|
* name and arguments and, when a result exists, its text. It
|
||||||
@@ -1143,7 +1169,24 @@ export class UI {
|
|||||||
this.$conversationsList.addEventListener('click', (event) => {
|
this.$conversationsList.addEventListener('click', (event) => {
|
||||||
const $item = event.target.closest('[data-conversation-id]');
|
const $item = event.target.closest('[data-conversation-id]');
|
||||||
if (!$item) return;
|
if (!$item) return;
|
||||||
this.handlers.onSelectConversation($item.dataset.conversationId);
|
const id = $item.dataset.conversationId;
|
||||||
|
const $rename = event.target.closest('.conversations__item-rename');
|
||||||
|
if ($rename) {
|
||||||
|
this._clearConfirm();
|
||||||
|
this.handlers.onRenameConversation(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const $delete = event.target.closest('.conversations__item-delete');
|
||||||
|
if ($delete) {
|
||||||
|
// Deleting is irreversible: the second press on the same
|
||||||
|
// button deletes, any other click cancels the request.
|
||||||
|
this._confirm(`conversation:${id}`, $delete, 'pending', () =>
|
||||||
|
this.handlers.onDeleteConversation(id),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._clearConfirm();
|
||||||
|
this.handlers.onSelectConversation(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Compose settings modal.
|
// Compose settings modal.
|
||||||
@@ -1474,15 +1517,17 @@ export class UI {
|
|||||||
value = readValue($modal);
|
value = readValue($modal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('invalid input:', error);
|
console.error('invalid input:', error);
|
||||||
for (const $textarea of $modal.querySelectorAll('.modal__textarea')) {
|
for (const $field of $modal.querySelectorAll(
|
||||||
$textarea.classList.add('modal__textarea--error');
|
'.modal__textarea, .modal__input',
|
||||||
|
)) {
|
||||||
|
$field.classList.add('modal__textarea--error');
|
||||||
}
|
}
|
||||||
$save.classList.add('modal__save--error');
|
$save.classList.add('modal__save--error');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
for (const $textarea of $modal.querySelectorAll(
|
for (const $field of $modal.querySelectorAll(
|
||||||
'.modal__textarea',
|
'.modal__textarea, .modal__input',
|
||||||
)) {
|
)) {
|
||||||
$textarea.classList.remove('modal__textarea--error');
|
$field.classList.remove('modal__textarea--error');
|
||||||
}
|
}
|
||||||
$save.classList.remove('modal__save--error');
|
$save.classList.remove('modal__save--error');
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -1491,7 +1536,7 @@ export class UI {
|
|||||||
close(value);
|
close(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
$modal.querySelector('textarea')?.focus();
|
$modal.querySelector('textarea, input')?.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,51 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template id="tpl-conversation-item">
|
<template id="tpl-conversation-item">
|
||||||
<button type="button" class="conversations__item">
|
<div class="conversations__item">
|
||||||
|
<button type="button" class="conversations__item-main">
|
||||||
<span class="conversations__item-title"></span>
|
<span class="conversations__item-title"></span>
|
||||||
|
<span class="conversations__item-dates">
|
||||||
|
<span class="conversations__item-created"></span>
|
||||||
<span class="conversations__item-updated"></span>
|
<span class="conversations__item-updated"></span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="conversations__item-btn conversations__item-rename"
|
||||||
|
aria-label="Rename conversation"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#edit"></use></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="conversations__item-btn conversations__item-delete"
|
||||||
|
aria-label="Delete conversation"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#trash"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-rename-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2>Rename Conversation</h2>
|
||||||
|
<button type="button" class="modal__close" aria-label="Cancel">
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#close"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="modal__input"
|
||||||
|
id="rename-input"
|
||||||
|
maxlength="256"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer">
|
||||||
|
<button type="button" class="modal__save" id="rename-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user