From 82e91cfc3ed8e04d3b3bc45f3fdf280978e2d41f Mon Sep 17 00:00:00 2001 From: Laura Date: Mon, 25 Aug 2025 22:45:03 +0200 Subject: [PATCH] title generation --- .gitignore | 2 +- README.md | 1 + chat.go | 8 +- env.go | 16 +++- example.config.yml | 2 + internal/title.txt | 24 ++++++ main.go | 1 + openrouter.go | 12 ++- prompts.go | 9 +++ static/css/chat.css | 40 ++++++++- static/css/icons/refresh.svg | 7 ++ static/index.html | 6 ++ static/js/chat.js | 143 +++++++++++++++++++++++++++----- title.go | 152 +++++++++++++++++++++++++++++++++++ 14 files changed, 392 insertions(+), 31 deletions(-) create mode 100644 internal/title.txt create mode 100644 static/css/icons/refresh.svg create mode 100644 title.go diff --git a/.gitignore b/.gitignore index 23bf12a..b63e590 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ config.yml -debug.json \ No newline at end of file +/*.json \ No newline at end of file diff --git a/README.md b/README.md index 80e70d6..0ce90d4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode - Edit, delete, or copy any message - Persistent settings for model, temperature, and other parameters - Full conversation control including clearing and modifying messages +- Title generation (and refresh) - Smooth UI updates with [morphdom](https://github.com/patrick-steele-idem/morphdom), selections, images, and other state are preserved during updates - Easy model selection: - Tags indicate if a model supports **tools**, **vision**, or **reasoning** diff --git a/chat.go b/chat.go index 2cf7362..7ef2610 100644 --- a/chat.go +++ b/chat.go @@ -140,7 +140,9 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { request.Messages = append(request.Messages, openrouter.SystemMessage(InternalToolsPrompt)) } - for index, message := range r.Messages { + for _, message := range r.Messages { + message.Text = strings.ReplaceAll(message.Text, "\r", "") + switch message.Role { case "system": request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{ @@ -211,8 +213,6 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { } request.Messages = append(request.Messages, msg) - default: - return nil, fmt.Errorf("[%d] invalid role: %q", index+1, message.Role) } } @@ -270,7 +270,7 @@ func HandleChat(w http.ResponseWriter, r *http.Request) { request.Messages = append(request.Messages, openrouter.SystemMessage("You have reached the maximum number of tool calls for this conversation. Provide your final response based on the information you have gathered.")) } - dump("debug.json", request) + dump("chat.json", request) tool, message, err := RunCompletion(ctx, response, request) if err != nil { diff --git a/env.go b/env.go index 904de75..ec2cd5c 100644 --- a/env.go +++ b/env.go @@ -19,7 +19,8 @@ type EnvTokens struct { } type EnvSettings struct { - CleanContent bool `json:"cleanup"` + CleanContent bool `json:"cleanup"` + TitleModel string `json:"title-model"` } type EnvUser struct { @@ -97,6 +98,16 @@ func (e *Environment) Init() error { log.Warning("Missing token.exa, web search unavailable") } + // check if github token is set + if e.Tokens.GitHub == "" { + log.Warning("Missing token.github, limited api requests") + } + + // default title model + if e.Settings.TitleModel == "" { + e.Settings.TitleModel = "google/gemini-2.5-flash-lite" + } + // create user lookup map e.Authentication.lookup = make(map[string]*EnvUser) @@ -122,7 +133,8 @@ func (e *Environment) Store() error { "$.tokens.exa": {yaml.HeadComment(" exa search api token (optional; used by search tools)")}, "$.tokens.github": {yaml.HeadComment(" github api token (optional; used by search tools)")}, - "$.settings.cleanup": {yaml.HeadComment(" normalize unicode in assistant output (optional; default: true)")}, + "$.settings.cleanup": {yaml.HeadComment(" normalize unicode in assistant output (optional; default: true)")}, + "$.settings.title-model": {yaml.HeadComment(" model used to generate titles (needs to have structured output support; default: google/gemini-2.5-flash-lite)")}, "$.authentication.enabled": {yaml.HeadComment(" require login with username and password")}, "$.authentication.users": {yaml.HeadComment(" list of users with bcrypt password hashes")}, diff --git a/example.config.yml b/example.config.yml index dc2d1d7..aa408d8 100644 --- a/example.config.yml +++ b/example.config.yml @@ -14,6 +14,8 @@ tokens: settings: # normalize unicode in assistant output (optional; default: true) cleanup: true + # model used to generate titles (needs to have structured output support; default: google/gemini-2.5-flash-lite) + title-model: google/gemini-2.5-flash-lite authentication: # require login with username and password diff --git a/internal/title.txt b/internal/title.txt new file mode 100644 index 0000000..a1e5ef3 --- /dev/null +++ b/internal/title.txt @@ -0,0 +1,24 @@ +You are a title generator for chat conversations. Your task is to create a concise, descriptive title that captures the main topic or purpose of the conversation. + +Guidelines: +- Create a title that is 3-8 words long +- Focus on the primary topic, question, or task being discussed +- Be specific rather than generic (avoid titles like "General Discussion" or "Chat") +- If the conversation covers multiple topics, focus on the most prominent or recent one +- Use clear, natural language that would help someone quickly understand what the chat is about + +{{if .Title}} +Important: The current title is "{{.Title}}". Generate a DIFFERENT title that: +- Captures a different aspect or angle of the conversation +- May focus on more recent developments in the chat +- Uses different wording and phrasing +- Do NOT simply rephrase or slightly modify the existing title +{{end}} + +Analyze the conversation below and generate an appropriate title. The conversation may contain system messages (instructions), user messages, and assistant responses. Focus on the actual conversation content, not the system instructions. + +Respond with a JSON object containing only the title, matching this format: + +{"title": "string"} + +ONLY respond with the json object, nothing else. Do not include extra formatting or markdown. \ No newline at end of file diff --git a/main.go b/main.go index 2947d1b..4570959 100644 --- a/main.go +++ b/main.go @@ -52,6 +52,7 @@ func main() { gr.Use(Authenticate) gr.Get("/-/stats/{id}", HandleStats) + gr.Post("/-/title", HandleTitle) gr.Post("/-/chat", HandleChat) }) diff --git a/openrouter.go b/openrouter.go index 5134ead..bc17c10 100644 --- a/openrouter.go +++ b/openrouter.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "github.com/revrost/go-openrouter" ) @@ -28,7 +29,16 @@ func OpenRouterStartStream(ctx context.Context, request openrouter.ChatCompletio func OpenRouterRun(ctx context.Context, request openrouter.ChatCompletionRequest) (openrouter.ChatCompletionResponse, error) { client := OpenRouterClient() - return client.CreateChatCompletion(ctx, request) + response, err := client.CreateChatCompletion(ctx, request) + if err != nil { + return response, err + } + + if len(response.Choices) == 0 { + return response, errors.New("no choices") + } + + return response, nil } func OpenRouterGetGeneration(ctx context.Context, id string) (openrouter.Generation, error) { diff --git a/prompts.go b/prompts.go index b995b34..fd18be2 100644 --- a/prompts.go +++ b/prompts.go @@ -31,11 +31,18 @@ var ( //go:embed internal/tools.txt InternalToolsPrompt string + //go:embed internal/title.txt + InternalTitlePrompt string + + InternalTitleTmpl *template.Template + Prompts []Prompt Templates = make(map[string]*template.Template) ) func init() { + InternalTitleTmpl = NewTemplate("internal-title", InternalTitlePrompt) + var err error Prompts, err = LoadPrompts() @@ -43,6 +50,8 @@ func init() { } func NewTemplate(name, text string) *template.Template { + text = strings.ReplaceAll(text, "\r", "") + return template.Must(template.New(name).Parse(text)) } diff --git a/static/css/chat.css b/static/css/chat.css index 03d7840..261d00f 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -133,12 +133,13 @@ body:not(.loading) #loading { gap: 5px; background: #1e2030; margin: auto; - margin-top: 30px; + margin-top: 40px; width: 100%; max-width: 1200px; - height: calc(100% - 30px); + height: calc(100% - 40px); border-top-left-radius: 6px; border-top-right-radius: 6px; + position: relative; } .hidden { @@ -150,6 +151,40 @@ body:not(.loading) #loading { display: none !important; } +#title { + display: flex; + align-items: center; + gap: 6px; + position: absolute; + top: -22px; + left: -4px; + font-style: italic; + padding-left: 22px; + transition: 150ms opacity; +} + +#title-text { + transition: 150ms; +} + +#title #title-refresh { + background-image: url(icons/refresh.svg); + width: 16px; + height: 16px; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); +} + +#title.refreshing #title-refresh { + animation: rotating-y 1.2s linear infinite; +} + +#title.refreshing #title-text { + filter: blur(3px); +} + #messages { display: flex; flex-direction: column; @@ -717,6 +752,7 @@ select { } body.loading #version, +#title-refresh, #loading .inner::after, .modal.loading .content::after, .reasoning .toggle::before, diff --git a/static/css/icons/refresh.svg b/static/css/icons/refresh.svg new file mode 100644 index 0000000..489d67f --- /dev/null +++ b/static/css/icons/refresh.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/index.html b/static/index.html index c229a66..5cca6f7 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,13 @@
+ +
+
diff --git a/static/js/chat.js b/static/js/chat.js index 1ce5215..0f276cf 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -1,6 +1,9 @@ (() => { const $version = document.getElementById("version"), $total = document.getElementById("total"), + $title = document.getElementById("title"), + $titleRefresh = document.getElementById("title-refresh"), + $titleText = document.getElementById("title-text"), $messages = document.getElementById("messages"), $chat = document.getElementById("chat"), $message = document.getElementById("message"), @@ -37,7 +40,8 @@ let autoScrolling = false, jsonMode = false, - searchTool = false; + searchTool = false, + chatTitle = false; let searchAvailable = false, activeMessage = null, @@ -51,6 +55,14 @@ $total.textContent = formatMoney(totalCost); } + function updateTitle() { + $title.classList.toggle("hidden", !messages.length); + + $titleText.textContent = chatTitle || (messages.length ? "New Chat" : ""); + + storeValue("title", chatTitle); + } + function updateScrollButton() { const bottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight); @@ -266,6 +278,8 @@ let timeout; _optCopy.addEventListener("click", () => { + this.stopEdit(); + clearTimeout(timeout); navigator.clipboard.writeText(this.#text); @@ -304,6 +318,8 @@ return; } + this.stopEdit(); + while (messages.length > index) { messages[messages.length - 1].delete(); } @@ -759,8 +775,6 @@ } } - let controller; - async function json(url) { try { const response = await fetch(url); @@ -778,6 +792,8 @@ } async function stream(url, options, callback) { + let aborted; + try { const response = await fetch(url, options); @@ -834,29 +850,32 @@ } } } catch (err) { - if (err.name !== "AbortError") { - callback({ - type: "error", - text: err.message, - }); + if (err.name === "AbortError") { + aborted = true; + + return; } + + callback({ + type: "error", + text: err.message, + }); } finally { - callback(false); + callback(aborted ? "aborted" : "done"); } } + let chatController; + function generate(cancel = false) { - if (controller) { - controller.abort(); + if (chatController) { + chatController.abort(); if (cancel) { return; } } - if (!$temperature.value) { - } - let temperature = parseFloat($temperature.value); if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) { @@ -888,7 +907,7 @@ pushMessage(); - controller = new AbortController(); + chatController = new AbortController(); $chat.classList.add("completing"); @@ -908,14 +927,16 @@ let message, generationID; - function finish() { + function finish(aborted = false) { if (!message) { return; } message.setState(false); - setTimeout(message.loadGenerationData.bind(message), 750, generationID); + if (!aborted) { + setTimeout(message.loadGenerationData.bind(message), 750, generationID); + } message = null; generationID = null; @@ -945,16 +966,24 @@ "Content-Type": "application/json", }, body: JSON.stringify(body), - signal: controller.signal, + signal: chatController.signal, }, chunk => { - if (!chunk) { - controller = null; + if (chunk === "aborted") { + finish(true); + + return; + } else if (chunk === "done") { + chatController = null; finish(); $chat.classList.remove("completing"); + if (!chatTitle && !titleController) { + refreshTitle(); + } + return; } @@ -999,6 +1028,65 @@ ); } + let titleController; + + async function refreshTitle() { + if (titleController) { + titleController.abort(); + } + + titleController = new AbortController(); + + const body = { + title: chatTitle || null, + messages: messages.map(message => message.getData()).filter(Boolean), + }; + + if (!body.messages.length) { + updateTitle(); + + return; + } + + $title.classList.add("refreshing"); + + try { + const response = await fetch("/-/title", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: titleController.signal, + }), + result = await response.json(); + + if (result.cost) { + totalCost += result.cost; + + updateTotalCost(); + } + + if (!response.ok || !result?.title) { + throw new Error(result?.error || response.statusText); + } + + chatTitle = result.title; + } catch (err) { + if (err.name === "AbortError") { + return; + } + + alert(err.message); + } + + titleController = null; + + updateTitle(); + + $title.classList.remove("refreshing"); + } + async function login() { const username = $username.value.trim(), password = $password.value.trim(); @@ -1147,6 +1235,10 @@ } }); + chatTitle = loadValue("title"); + + updateTitle(); + scroll(); // small fix, sometimes when hard reloading we don't scroll all the way @@ -1235,11 +1327,12 @@ const message = new Message($role.value, "", text, attachments); clearAttachments(); + updateTitle(); return message; } - $total.addEventListener("auxclick", (event) => { + $total.addEventListener("auxclick", event => { if (event.button !== 1) { return; } @@ -1249,6 +1342,10 @@ updateTotalCost(); }); + $titleRefresh.addEventListener("click", () => { + refreshTitle(); + }); + $messages.addEventListener("scroll", () => { updateScrollButton(); }); @@ -1417,6 +1514,10 @@ } clearMessages(); + + chatTitle = false; + + updateTitle(); }); $export.addEventListener("click", () => { diff --git a/title.go b/title.go new file mode 100644 index 0000000..3ce5b2e --- /dev/null +++ b/title.go @@ -0,0 +1,152 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/revrost/go-openrouter" + "github.com/revrost/go-openrouter/jsonschema" +) + +type TitleRequest struct { + Title *string `json:"title"` + Messages []Message `json:"messages"` +} + +type TitleResponse struct { + Title string `json:"title"` + Cost float64 `json:"cost,omitempty"` +} + +var ( + titleReplacer = strings.NewReplacer( + "\r", "", + "\n", "\\n", + "\t", "\\t", + ) + + titleSchema, _ = jsonschema.GenerateSchema[TitleResponse]() +) + +func HandleTitle(w http.ResponseWriter, r *http.Request) { + debug("parsing title") + + var raw TitleRequest + + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + RespondJson(w, http.StatusBadRequest, map[string]any{ + "error": err.Error(), + }) + + return + } + + debug("preparing request") + + messages := make([]string, 0, len(raw.Messages)) + + for _, message := range raw.Messages { + switch message.Role { + case "system", "assistant", "user": + text := message.Text + + if len(message.Files) != 0 { + if text != "" { + text += "\n" + } + + files := make([]string, len(message.Files)) + + for i, file := range message.Files { + files[i] = file.Name + } + + text += fmt.Sprintf("FILES: %s", strings.Join(files, ", ")) + } + + if text != "" { + text = strings.TrimSpace(text) + text = titleReplacer.Replace(text) + + messages = append(messages, fmt.Sprintf("%s: %s", strings.ToUpper(message.Role), text)) + } + } + } + + if len(messages) == 0 { + RespondJson(w, http.StatusBadRequest, map[string]any{ + "error": "no valid messages", + }) + + return + } + + var prompt strings.Builder + + if err := InternalTitleTmpl.Execute(&prompt, raw); err != nil { + RespondJson(w, http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + }) + + return + } + + request := openrouter.ChatCompletionRequest{ + Model: env.Settings.TitleModel, + Messages: []openrouter.ChatCompletionMessage{ + openrouter.SystemMessage(prompt.String()), + openrouter.UserMessage(strings.Join(messages, "\n")), + }, + Temperature: 0.25, + MaxTokens: 100, + ResponseFormat: &openrouter.ChatCompletionResponseFormat{ + Type: openrouter.ChatCompletionResponseFormatTypeJSONSchema, + JSONSchema: &openrouter.ChatCompletionResponseFormatJSONSchema{ + Name: "chat_title", + Schema: titleSchema, + Strict: true, + }, + }, + Usage: &openrouter.IncludeUsage{ + Include: true, + }, + } + + if raw.Title != nil { + request.Temperature = 0.4 + } + + dump("title.json", request) + + debug("generating title") + + response, err := OpenRouterRun(r.Context(), request) + if err != nil { + RespondJson(w, http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + }) + + return + } + + choice := response.Choices[0].Message.Content.Text + cost := response.Usage.Cost + + var result TitleResponse + + err = json.Unmarshal([]byte(choice), &result) + if err != nil { + RespondJson(w, http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + "cost": cost, + }) + + return + } + + result.Cost = cost + + RespondJson(w, http.StatusOK, result) +}