1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-07 08:15:31 +00:00

title generation

This commit is contained in:
Laura
2025-08-25 22:45:03 +02:00
parent 3eac1a0795
commit 82e91cfc3e
14 changed files with 392 additions and 31 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
config.yml
debug.json
/*.json

View File

@@ -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**

View File

@@ -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 {

16
env.go
View File

@@ -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")},

View File

@@ -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

24
internal/title.txt Normal file
View File

@@ -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.

View File

@@ -52,6 +52,7 @@ func main() {
gr.Use(Authenticate)
gr.Get("/-/stats/{id}", HandleStats)
gr.Post("/-/title", HandleTitle)
gr.Post("/-/chat", HandleChat)
})

View File

@@ -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) {

View File

@@ -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))
}

View File

@@ -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,

View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 879 B

View File

@@ -26,7 +26,13 @@
</div>
<div id="page">
<div id="title" class="hidden">
<button id="title-refresh"></button>
<div id="title-text"></div>
</div>
<div id="messages"></div>
<div id="chat">
<button id="top" class="hidden" title="Scroll to top"></button>
<button id="bottom" class="hidden" title="Scroll to bottom"></button>

View File

@@ -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", () => {

152
title.go Normal file
View File

@@ -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)
}