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