mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +08:00
Closes https://linear.app/codercom/issue/CODAGT-268 ## Problem The chat UI collapses large pastes (>=10 lines or >=1000 chars) into a synthetic `pasted-text-*.txt` attachment. A chat created with only such an attachment had no title input anywhere: the create path derived `titleSource` only from text and file-reference parts (so the chat was named "New Chat"), async auto-titling extracted text the same way and silently skipped generation, and the manual propose/regenerate paths returned an empty title for the same reason. The regular prompt path already inlines these files for the model; only the title paths were blind. ## Fix Add a single title-input derivation in `chatprompt` and use it everywhere: - `chatprompt.TitleText` joins text and file-reference parts (unchanged formatting), and falls back to synthetic pasted-text attachment content (truncated to a 16 KiB title budget) when they yield nothing. - `chatprompt.SyntheticPasteFileIDs` identifies paste attachments; `chatprompt.FallbackTitle` consolidates the previously duplicated `chatTitleFromMessage` / `fallbackChatTitle`. - Chat creation captures paste blob references while validating file parts (the file row was already loaded there) and derives `titleSource` via `TitleText`. Only the create path derives titles; message send and edit reuse the same validation without copying any blob data. - `GenerateChatTitleAsync` and the manual propose/regenerate paths resolve paste content via `titlePasteText`, which only queries when a visible user message has no other title text, so chats with typed text never incur a file fetch. - Title-path paste fetches are bounded: a new `GetChatFileDataPrefixesByIDs` query returns only a `substr` prefix (`chatprompt.TitlePasteBytePrefix`, 64 KiB = 4 bytes x the 16 Ki-rune title budget) so full blobs (up to 10 MiB each) never leave the database for titling, and `chatprompt.TitlePasteText` applies the same bound to the create path which already holds the loaded row. Deliberate side effect: because generation-time extraction now matches create-time `titleSource` exactly, file-reference-only chats also become eligible for AI titles. They were previously skipped by the same derivation mismatch. Non-goals: no frontend changes (attachment chip UX stays as is), and non-synthetic user-uploaded `.txt` files still yield "New Chat". ## Testing - Unit tests for `TitleText`, `TitlePasteText`, `SyntheticPasteFileIDs`, `FallbackTitle`, `titleInput`, `titlePasteText`, and paste-aware `extractManualTitleTurns`. - Real-database test for `GetChatFileDataPrefixesByIDs` (prefix shorter and longer than stored data) plus dbauthz coverage for the new query. - Integration tests: paste-only create gets a fallback title from the paste content, async title generation fires with the paste content as input, and `RegenerateChatTitle` works on a paste-only chat. > This PR was written by [Mux](https://mux.coder.com) on Mike's behalf.
116 lines
3.7 KiB
Go
116 lines
3.7 KiB
Go
package chatprompt
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
stringutil "github.com/coder/coder/v2/coderd/util/strings"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// syntheticPasteTitleBudget caps, in runes, how much of a pasted-text
|
|
// attachment feeds title generation. It is far smaller than
|
|
// syntheticPasteInlineBudget because it only seeds a short title, not
|
|
// the model prompt.
|
|
const syntheticPasteTitleBudget = 16 * 1024
|
|
|
|
// TitlePasteBytePrefix caps, in bytes, how much of a pasted-text blob
|
|
// feeds title derivation: four bytes per rune (the UTF-8 maximum)
|
|
// covers syntheticPasteTitleBudget runes. Database callers pass it to
|
|
// GetChatFileDataPrefixesByIDs to bound the fetch itself.
|
|
const TitlePasteBytePrefix = 4 * syntheticPasteTitleBudget
|
|
|
|
// TitlePasteText converts pasted-text content to TitleText input,
|
|
// copying at most TitlePasteBytePrefix bytes. Every caller that
|
|
// builds a pasteText map must use it so all derivation paths feed
|
|
// TitleText identical strings.
|
|
func TitlePasteText(data []byte) string {
|
|
return string(data[:min(len(data), TitlePasteBytePrefix)])
|
|
}
|
|
|
|
// TitleText derives title-generation input from message parts. Text
|
|
// and file-reference parts are joined in part order. When they yield
|
|
// nothing, the content of synthetic pasted-text attachments is used
|
|
// instead, looked up in pasteText by file ID and truncated to
|
|
// syntheticPasteTitleBudget runes per file.
|
|
//
|
|
// The chat-creation fallback title and both title-generation paths
|
|
// must derive their input through this function: auto-titling only
|
|
// proceeds when the current title equals FallbackTitle of this exact
|
|
// string, so a drift between derivations silently disables it.
|
|
func TitleText(parts []codersdk.ChatMessagePart, pasteText map[uuid.UUID]string) string {
|
|
texts := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
switch part.Type {
|
|
case codersdk.ChatMessagePartTypeText:
|
|
text := strings.TrimSpace(part.Text)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
texts = append(texts, text)
|
|
case codersdk.ChatMessagePartTypeFileReference:
|
|
texts = append(texts, fileReferencePartToText(part))
|
|
}
|
|
}
|
|
if joined := strings.TrimSpace(strings.Join(texts, " ")); joined != "" {
|
|
return joined
|
|
}
|
|
|
|
pastes := make([]string, 0, len(pasteText))
|
|
for _, part := range parts {
|
|
if part.Type != codersdk.ChatMessagePartTypeFile || !part.FileID.Valid {
|
|
continue
|
|
}
|
|
content := strings.TrimSpace(pasteText[part.FileID.UUID])
|
|
if content == "" {
|
|
continue
|
|
}
|
|
pastes = append(pastes, stringutil.Truncate(content, syntheticPasteTitleBudget))
|
|
}
|
|
return strings.TrimSpace(strings.Join(pastes, "\n\n"))
|
|
}
|
|
|
|
// SyntheticPasteFileIDs returns the file IDs of file parts that are
|
|
// synthetic pasted-text attachments created by the chat UI. Callers
|
|
// resolve these to file content and pass the result to TitleText.
|
|
func SyntheticPasteFileIDs(parts []codersdk.ChatMessagePart) []uuid.UUID {
|
|
var ids []uuid.UUID
|
|
for _, part := range parts {
|
|
if part.Type != codersdk.ChatMessagePartTypeFile || !part.FileID.Valid {
|
|
continue
|
|
}
|
|
if !IsSyntheticPaste(part.Name, part.MediaType) {
|
|
continue
|
|
}
|
|
ids = append(ids, part.FileID.UUID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// FallbackTitle derives a deterministic chat title from title text:
|
|
// the first six words, ellipsized when truncated, capped at 80 runes.
|
|
// Empty input yields "New Chat".
|
|
func FallbackTitle(message string) string {
|
|
const maxWords = 6
|
|
const maxRunes = 80
|
|
|
|
words := strings.Fields(message)
|
|
if len(words) == 0 {
|
|
return "New Chat"
|
|
}
|
|
|
|
truncated := false
|
|
if len(words) > maxWords {
|
|
words = words[:maxWords]
|
|
truncated = true
|
|
}
|
|
|
|
title := strings.Join(words, " ")
|
|
if truncated {
|
|
return stringutil.Truncate(title, maxRunes-1) + "…"
|
|
}
|
|
|
|
return stringutil.Truncate(title, maxRunes)
|
|
}
|