Add default conversation title
This commit is contained in:
@@ -651,6 +651,17 @@ func (inf *Inference) Run() (err error) {
|
|||||||
slog.String("id", saved.Id),
|
slog.String("id", saved.Id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Give an untitled conversation its title before the model
|
||||||
|
// runs, so a slow completion does not delay the title. A
|
||||||
|
// failure here costs the title, not the run: the next user
|
||||||
|
// message tries again.
|
||||||
|
if err := c.setTitle(ctx, *req.Message); err != nil {
|
||||||
|
log.WarnContext(
|
||||||
|
ctx, "failed to derive conversation title",
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Continue from the updated history.
|
// Continue from the updated history.
|
||||||
history = append(history, *req.Message)
|
history = append(history, *req.Message)
|
||||||
}
|
}
|
||||||
@@ -1147,7 +1158,22 @@ func (c *Conversation) InsertMessage(
|
|||||||
return nil, ErrBusy
|
return nil, ErrBusy
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.insertMessage(ctx, nil, msg, nil, nil)
|
saved, err := c.insertMessage(ctx, nil, msg, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user message can give an untitled conversation its title.
|
||||||
|
// A failure here costs the title, not the saved message.
|
||||||
|
if msg.Role == llm.RoleUser {
|
||||||
|
if err := c.setTitle(ctx, msg); err != nil {
|
||||||
|
c.log.WarnContext(
|
||||||
|
ctx, "failed to derive conversation title",
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMessage replaces a message's model-visible content; its role may
|
// UpdateMessage replaces a message's model-visible content; its role may
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package conversation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/db"
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxTitleLength is the maximum length of a conversation title in
|
||||||
|
// Unicode code points.
|
||||||
|
const MaxTitleLength = 256
|
||||||
|
|
||||||
|
// deriveTitle returns a title from the first line of text that
|
||||||
|
// contains non-whitespace characters. It replaces runs of
|
||||||
|
// whitespace on that line with single spaces and truncates to
|
||||||
|
// MaxTitleLength runes. It returns "" if text has no
|
||||||
|
// non-whitespace characters.
|
||||||
|
func deriveTitle(text string) string {
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
title := strings.Join(strings.Fields(line), " ")
|
||||||
|
if title == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(title) <= MaxTitleLength {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
runes := []rune(title)
|
||||||
|
return string(runes[:MaxTitleLength])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTitle derives a title from msg and sets the conversation
|
||||||
|
// title if the conversation has no title yet. It does nothing if
|
||||||
|
// msg has no non-whitespace text or if the conversation already
|
||||||
|
// has a title.
|
||||||
|
func (c *Conversation) setTitle(
|
||||||
|
ctx context.Context,
|
||||||
|
msg llm.Message,
|
||||||
|
) error {
|
||||||
|
title := deriveTitle(msg.Text())
|
||||||
|
if title == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := c.dbw.UpdateConversationTitleIfUnset(
|
||||||
|
ctx,
|
||||||
|
db.UpdateConversationTitleIfUnsetParams{
|
||||||
|
Id: c.id,
|
||||||
|
Title: title,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// sql.ErrNoRows means the conversation already has a title (or
|
||||||
|
// does not exist).
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update conversation title: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package conversation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/db"
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeriveTitle(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single line",
|
||||||
|
text: "Check the cameras",
|
||||||
|
want: "Check the cameras",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank lines come before the first line",
|
||||||
|
text: "\n \n\t\nCheck the cameras\nSecond line",
|
||||||
|
want: "Check the cameras",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spaces inside the line collapse to one",
|
||||||
|
text: "Can you\tcheck the cameras? ",
|
||||||
|
want: "Can you check the cameras?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading indentation is trimmed",
|
||||||
|
text: " def main():",
|
||||||
|
want: "def main():",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a line of MaxTitleLength characters stays whole",
|
||||||
|
text: strings.Repeat("x", MaxTitleLength),
|
||||||
|
want: strings.Repeat("x", MaxTitleLength),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a longer line cuts to MaxTitleLength characters",
|
||||||
|
text: strings.Repeat("x", 300),
|
||||||
|
want: strings.Repeat("x", MaxTitleLength),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the cut counts characters, not bytes",
|
||||||
|
text: strings.Repeat("文", 300),
|
||||||
|
want: strings.Repeat("文", MaxTitleLength),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty text",
|
||||||
|
text: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spaces only",
|
||||||
|
text: " \n\t \n",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := deriveTitle(tt.text); got != tt.want {
|
||||||
|
t.Fatalf("deriveTitle(%q) = %q, want %q", tt.text, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func title(t *testing.T, c *Conversation) *string {
|
||||||
|
t.Helper()
|
||||||
|
conv, err := c.dbr.SelectConversationId(t.Context(), c.id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectConversationId: %v", err)
|
||||||
|
}
|
||||||
|
return conv.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertMessageTitlesConversation(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// The first user message sets the title from its first non-empty
|
||||||
|
// line.
|
||||||
|
c := newTestConversation(t, nil)
|
||||||
|
if _, err := c.InsertMessage(
|
||||||
|
ctx, *userMessage(" Check the cameras\nplease"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("InsertMessage: %v", err)
|
||||||
|
}
|
||||||
|
if got := title(t, c); got == nil || *got != "Check the cameras" {
|
||||||
|
t.Fatalf("Title = %v, want Check the cameras", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later user message does not overwrite an existing title.
|
||||||
|
if _, err := c.InsertMessage(
|
||||||
|
ctx, *userMessage("A different subject"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("InsertMessage: %v", err)
|
||||||
|
}
|
||||||
|
if got := title(t, c); got == nil || *got != "Check the cameras" {
|
||||||
|
t.Fatalf("Title = %v, want Check the cameras", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-user messages do not set the title.
|
||||||
|
c2 := newTestConversation(t, nil)
|
||||||
|
assistant := llm.Message{
|
||||||
|
Role: llm.RoleAssistant,
|
||||||
|
ContentParts: []llm.ContentPart{{
|
||||||
|
Type: llm.ContentTypeText,
|
||||||
|
Text: "Hello there",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if _, err := c2.InsertMessage(ctx, assistant); err != nil {
|
||||||
|
t.Fatalf("InsertMessage: %v", err)
|
||||||
|
}
|
||||||
|
if got := title(t, c2); got != nil {
|
||||||
|
t.Fatalf("Title = %v, want nil", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A title set before the first user message is kept.
|
||||||
|
c3 := newTestConversation(t, nil)
|
||||||
|
if _, err := c3.dbw.UpdateConversationTitle(
|
||||||
|
ctx,
|
||||||
|
db.UpdateConversationTitleParams{
|
||||||
|
Id: c3.id,
|
||||||
|
Title: "Nightly report",
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("UpdateConversationTitle: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := c3.InsertMessage(
|
||||||
|
ctx, *userMessage("A different subject"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("InsertMessage: %v", err)
|
||||||
|
}
|
||||||
|
if got := title(t, c3); got == nil || *got != "Nightly report" {
|
||||||
|
t.Fatalf("Title = %v, want Nightly report", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferenceTitlesConversation(t *testing.T) {
|
||||||
|
var (
|
||||||
|
ctx = t.Context()
|
||||||
|
client = newTestModelClient(
|
||||||
|
t,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{
|
||||||
|
"choices":[{
|
||||||
|
"message":{"role":"assistant","content":"ok"},
|
||||||
|
"finish_reason":"stop"
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inference with a new user message sets an unset title.
|
||||||
|
c := newTestConversation(t, nil)
|
||||||
|
if err := infer(ctx, c, client, InferenceRequest{
|
||||||
|
Message: userMessage("Turn off the porch light"),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("infer: %v", err)
|
||||||
|
}
|
||||||
|
if got := title(t, c); got == nil || *got != "Turn off the porch light" {
|
||||||
|
t.Fatalf("Title = %v, want Turn off the porch light", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later inference does not overwrite the title.
|
||||||
|
if err := infer(ctx, c, client, InferenceRequest{
|
||||||
|
Message: userMessage("A different subject"),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("infer: %v", err)
|
||||||
|
}
|
||||||
|
if got := title(t, c); got == nil || *got != "Turn off the porch light" {
|
||||||
|
t.Fatalf("Title = %v, want Turn off the porch light", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inference does not overwrite a title that was already set.
|
||||||
|
c2 := newTestConversation(t, nil)
|
||||||
|
if _, err := c2.dbw.UpdateConversationTitle(
|
||||||
|
ctx,
|
||||||
|
db.UpdateConversationTitleParams{
|
||||||
|
Id: c2.id,
|
||||||
|
Title: "Nightly report",
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("UpdateConversationTitle: %v", err)
|
||||||
|
}
|
||||||
|
if err := infer(ctx, c2, client, InferenceRequest{
|
||||||
|
Message: userMessage("A different subject"),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("infer: %v", err)
|
||||||
|
}
|
||||||
|
if got := title(t, c2); got == nil || *got != "Nightly report" {
|
||||||
|
t.Fatalf("Title = %v, want Nightly report", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-10
@@ -288,7 +288,7 @@ func (q *Queries) SelectConversationsUserId(ctx context.Context, userId string)
|
|||||||
|
|
||||||
const updateConversationTitle = `
|
const updateConversationTitle = `
|
||||||
UPDATE conversations
|
UPDATE conversations
|
||||||
SET title = ?
|
SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING created_at, id, title, updated_at, user_id
|
RETURNING created_at, id, title, updated_at, user_id
|
||||||
`
|
`
|
||||||
@@ -300,15 +300,9 @@ type UpdateConversationTitleParams struct {
|
|||||||
Title string
|
Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConversationTitle sets the title of one conversation and
|
// UpdateConversationTitle sets the title for a conversation, bumps
|
||||||
// returns the updated row.
|
// updated_at, and returns the updated row. It returns sql.ErrNoRows
|
||||||
//
|
// if no conversation has that id.
|
||||||
// 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(
|
func (q *Queries) UpdateConversationTitle(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
arg UpdateConversationTitleParams,
|
arg UpdateConversationTitleParams,
|
||||||
@@ -344,6 +338,61 @@ func (q *Queries) UpdateConversationTitle(
|
|||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateConversationTitleIfUnset = `
|
||||||
|
UPDATE conversations
|
||||||
|
SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ? AND title IS NULL
|
||||||
|
RETURNING created_at, id, title, updated_at, user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
// UpdateConversationTitleIfUnsetParams holds arguments for
|
||||||
|
// UpdateConversationTitleIfUnset.
|
||||||
|
type UpdateConversationTitleIfUnsetParams struct {
|
||||||
|
Id string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConversationTitleIfUnset sets the first title for a
|
||||||
|
// conversation when title is NULL, bumps updated_at, and returns
|
||||||
|
// the updated row. It prevents a caller from overwriting a title
|
||||||
|
// set by a user or a scheduled job. It returns sql.ErrNoRows if
|
||||||
|
// the conversation does not exist or already has a title; callers
|
||||||
|
// treat both cases as no-op.
|
||||||
|
func (q *Queries) UpdateConversationTitleIfUnset(
|
||||||
|
ctx context.Context,
|
||||||
|
arg UpdateConversationTitleIfUnsetParams,
|
||||||
|
) (*Conversation, error) {
|
||||||
|
row := q.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
updateConversationTitleIfUnset,
|
||||||
|
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("UpdateConversationTitleIfUnset: %w", err)
|
||||||
|
}
|
||||||
|
if c.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("UpdateConversationTitleIfUnset: parse created_at: %w", err)
|
||||||
|
}
|
||||||
|
if c.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("UpdateConversationTitleIfUnset: parse updated_at: %w", err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
const deleteConversationId = `
|
const deleteConversationId = `
|
||||||
DELETE FROM conversations
|
DELETE FROM conversations
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
+75
-17
@@ -136,20 +136,16 @@ func testConversation(t *testing.T, ctx context.Context, db *SQLite) {
|
|||||||
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
|
// Rename the conversation. The rename must refresh updated_at:
|
||||||
// trigger moves updated_at, so the rename can show that it
|
// plant a known-old timestamp, then check that the statement
|
||||||
// keeps the timestamp.
|
// moved it.
|
||||||
if _, err := qw.InsertMessage(ctx, InsertMessageParams{
|
const staleUpdatedAt = "2020-01-01T00:00:00Z"
|
||||||
ConversationId: c.Id,
|
if _, err := db.Writer.ExecContext(
|
||||||
Sequence: 1,
|
ctx,
|
||||||
Role: MessageRoleUser,
|
"UPDATE conversations SET updated_at = ? WHERE id = ?",
|
||||||
Content: jsontext.Value(`{"role":"user","content":"Hello"}`),
|
staleUpdatedAt, c.Id,
|
||||||
}); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("InsertMessage: %v", err)
|
t.Fatalf("plant stale updated_at: %v", err)
|
||||||
}
|
|
||||||
before, err := qr.SelectConversationId(ctx, c.Id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SelectConversationId: %v", err)
|
|
||||||
}
|
}
|
||||||
renamed, err := qw.UpdateConversationTitle(ctx, UpdateConversationTitleParams{
|
renamed, err := qw.UpdateConversationTitle(ctx, UpdateConversationTitleParams{
|
||||||
Id: c.Id,
|
Id: c.Id,
|
||||||
@@ -161,10 +157,14 @@ func testConversation(t *testing.T, ctx context.Context, db *SQLite) {
|
|||||||
if renamed.Title == nil || *renamed.Title != "renamed" {
|
if renamed.Title == nil || *renamed.Title != "renamed" {
|
||||||
t.Fatalf("Title = %v, want renamed", renamed.Title)
|
t.Fatalf("Title = %v, want renamed", renamed.Title)
|
||||||
}
|
}
|
||||||
if !renamed.UpdatedAt.Equal(before.UpdatedAt) {
|
stale, err := time.Parse(time.RFC3339, staleUpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse staleUpdatedAt: %v", err)
|
||||||
|
}
|
||||||
|
if !renamed.UpdatedAt.After(stale) {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"UpdatedAt = %v, want %v: a rename must not move updated_at",
|
"UpdatedAt = %v, want a time after %v",
|
||||||
renamed.UpdatedAt, before.UpdatedAt,
|
renamed.UpdatedAt, stale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +175,64 @@ func testConversation(t *testing.T, ctx context.Context, db *SQLite) {
|
|||||||
}); !errors.Is(err, sql.ErrNoRows) {
|
}); !errors.Is(err, sql.ErrNoRows) {
|
||||||
t.Fatalf("UpdateConversationTitle(missing) = %v, want sql.ErrNoRows", err)
|
t.Fatalf("UpdateConversationTitle(missing) = %v, want sql.ErrNoRows", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The guarded update sets the title of an untitled
|
||||||
|
// conversation, and does not touch one that is titled. It must
|
||||||
|
// refresh updated_at: plant the stale timestamp, then check
|
||||||
|
// that the statement moved it.
|
||||||
|
blank, err := qw.InsertConversation(ctx, InsertConversationParams{
|
||||||
|
UserId: u.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InsertConversation: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Writer.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"UPDATE conversations SET updated_at = ? WHERE id = ?",
|
||||||
|
staleUpdatedAt, blank.Id,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("plant stale updated_at: %v", err)
|
||||||
|
}
|
||||||
|
titled, err := qw.UpdateConversationTitleIfUnset(
|
||||||
|
ctx,
|
||||||
|
UpdateConversationTitleIfUnsetParams{
|
||||||
|
Id: blank.Id,
|
||||||
|
Title: "derived",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateConversationTitleIfUnset: %v", err)
|
||||||
|
}
|
||||||
|
if titled.Title == nil || *titled.Title != "derived" {
|
||||||
|
t.Fatalf("Title = %v, want derived", titled.Title)
|
||||||
|
}
|
||||||
|
if !titled.UpdatedAt.After(stale) {
|
||||||
|
t.Fatalf(
|
||||||
|
"UpdatedAt = %v, want a time after %v",
|
||||||
|
titled.UpdatedAt, stale,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
}{
|
||||||
|
{name: "titled twice", id: blank.Id},
|
||||||
|
{name: "renamed conversation", id: c.Id},
|
||||||
|
{name: "missing conversation", id: "missing"},
|
||||||
|
} {
|
||||||
|
if _, err := qw.UpdateConversationTitleIfUnset(
|
||||||
|
ctx,
|
||||||
|
UpdateConversationTitleIfUnsetParams{
|
||||||
|
Id: tt.id,
|
||||||
|
Title: "second",
|
||||||
|
},
|
||||||
|
); !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
t.Fatalf(
|
||||||
|
"UpdateConversationTitleIfUnset(%s) = %v, want sql.ErrNoRows",
|
||||||
|
tt.name, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMessaging(t *testing.T, ctx context.Context, db *SQLite) {
|
func testMessaging(t *testing.T, ctx context.Context, db *SQLite) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/conversation"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/db"
|
"code.chimeric.al/chimerical/odidere/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,17 +130,12 @@ 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
|
// updateConversation renames one conversation. The handler trims
|
||||||
// the title, then rejects an empty title or a title longer than
|
// the title, then rejects an empty title or a title longer than
|
||||||
// maxTitleLength with 400. A conversation that does not exist, or
|
// conversation.MaxTitleLength with 400. A conversation that does
|
||||||
// belongs to another user, returns 404. The rename is allowed while
|
// not exist, or belongs to another user, returns 404. The rename
|
||||||
// inference runs: a title is not a message, and a concurrent
|
// is allowed while inference runs: a title is not a message, and a
|
||||||
// inference does not read or write it.
|
// concurrent inference does not read or write it.
|
||||||
func (svc *Service) updateConversation(w http.ResponseWriter, r *http.Request) {
|
func (svc *Service) updateConversation(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
ctx = r.Context()
|
ctx = r.Context()
|
||||||
@@ -165,7 +161,7 @@ func (svc *Service) updateConversation(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(req.Title) > maxTitleLength {
|
if utf8.RuneCountInString(req.Title) > conversation.MaxTitleLength {
|
||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export function extractText(content) {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conversations created from this UI have no title. Their label
|
// CONVERSATION_DATE formats conversation timestamps in the browser
|
||||||
// falls back to the creation date, so the picker stays readable.
|
// locale. Until the server sets a title from the first user message, an
|
||||||
// Both dates render in the browser locale.
|
// untitled conversation uses its formatted creation date as its label.
|
||||||
const CONVERSATION_DATE = new Intl.DateTimeFormat(undefined, {
|
const CONVERSATION_DATE = new Intl.DateTimeFormat(undefined, {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
|
|||||||
Reference in New Issue
Block a user