From 225cf59b4e2b1479c7c66b91b642a1e87fcd0861 Mon Sep 17 00:00:00 2001 From: Laura Date: Thu, 28 Aug 2025 16:37:48 +0200 Subject: [PATCH] improvements --- README.md | 2 - chat.go | 20 ++++++-- prompts.go | 31 +++++++++--- prompts/analyst.txt | 2 +- prompts/engineer.txt | 2 +- prompts/normal.txt | 2 +- prompts/physics.txt | 2 +- prompts/researcher.txt | 2 +- prompts/reviewer.txt | 2 +- prompts/scripts.txt | 2 +- static/css/chat.css | 59 ++++++++++++++++++++-- static/css/icons/collapse.svg | 7 +++ static/js/chat.js | 58 ++++++++++++++++------ static/js/lib.js | 93 +++++++++++++++++++++++++++++++++++ 14 files changed, 244 insertions(+), 40 deletions(-) create mode 100644 static/css/icons/collapse.svg diff --git a/README.md b/README.md index c46d925..802c77a 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,6 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode ## TODO - improved custom prompts -- collapse messages -- user defined timezone - settings - auto-retry on edit - ctrl+enter vs enter for sending diff --git a/chat.go b/chat.go index 2609024..601418d 100644 --- a/chat.go +++ b/chat.go @@ -39,14 +39,24 @@ type Reasoning struct { Tokens int `json:"tokens"` } +type Tools struct { + JSON bool `json:"json"` + Search bool `json:"search"` +} + +type Metadata struct { + Timezone string `json:"timezone"` + Platform string `json:"platform"` +} + type Request struct { Prompt string `json:"prompt"` Model string `json:"model"` Temperature float64 `json:"temperature"` Iterations int64 `json:"iterations"` - JSON bool `json:"json"` - Search bool `json:"search"` + Tools Tools `json:"tools"` Reasoning Reasoning `json:"reasoning"` + Metadata Metadata `json:"metadata"` Messages []Message `json:"messages"` } @@ -119,13 +129,13 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { } } - if model.JSON && r.JSON { + if model.JSON && r.Tools.JSON { request.ResponseFormat = &openrouter.ChatCompletionResponseFormat{ Type: openrouter.ChatCompletionResponseFormatTypeJSONObject, } } - prompt, err := BuildPrompt(r.Prompt, model) + prompt, err := BuildPrompt(r.Prompt, r.Metadata, model) if err != nil { return nil, err } @@ -134,7 +144,7 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { request.Messages = append(request.Messages, openrouter.SystemMessage(prompt)) } - if model.Tools && r.Search && env.Tokens.Exa != "" { + if model.Tools && r.Tools.Search && env.Tokens.Exa != "" { request.Tools = GetSearchTools() request.ToolChoice = "auto" diff --git a/prompts.go b/prompts.go index fd18be2..924e803 100644 --- a/prompts.go +++ b/prompts.go @@ -15,9 +15,10 @@ import ( ) type PromptData struct { - Name string - Slug string - Date string + Name string + Slug string + Date string + Platform string } type Prompt struct { @@ -87,7 +88,7 @@ func LoadPrompts() ([]Prompt, error) { prompt := Prompt{ Key: strings.Replace(filepath.Base(path), ".txt", "", 1), Name: strings.TrimSpace(string(body[:index])), - Text: strings.TrimSpace(string(body[:index+3])), + Text: strings.TrimSpace(string(body[index+3:])), } prompts = append(prompts, prompt) @@ -110,7 +111,7 @@ func LoadPrompts() ([]Prompt, error) { return prompts, nil } -func BuildPrompt(name string, model *Model) (string, error) { +func BuildPrompt(name string, metadata Metadata, model *Model) (string, error) { if name == "" { return "", nil } @@ -120,12 +121,26 @@ func BuildPrompt(name string, model *Model) (string, error) { return "", fmt.Errorf("unknown prompt: %q", name) } + tz := time.UTC + + if metadata.Timezone != "" { + parsed, err := time.LoadLocation(metadata.Timezone) + if err == nil { + tz = parsed + } + } + + if metadata.Platform == "" { + metadata.Platform = "Unknown" + } + var buf bytes.Buffer err := tmpl.Execute(&buf, PromptData{ - Name: model.Name, - Slug: model.ID, - Date: time.Now().Format(time.RFC1123), + Name: model.Name, + Slug: model.ID, + Date: time.Now().In(tz).Format(time.RFC1123), + Platform: metadata.Platform, }) if err != nil { diff --git a/prompts/analyst.txt b/prompts/analyst.txt index f4eea85..b1d085f 100644 --- a/prompts/analyst.txt +++ b/prompts/analyst.txt @@ -1,6 +1,6 @@ Data Analyst --- -You are {{ .Name }} ({{ .Slug }}), an expert data analyst who transforms raw data into clear, actionable insights. Today is {{ .Date }}. +You are {{ .Name }} ({{ .Slug }}), an expert data analyst who transforms raw data into clear, actionable insights. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`. ## Role & Expertise - **Primary Role**: Data analyst with expertise in statistical analysis, pattern recognition, and business intelligence diff --git a/prompts/engineer.txt b/prompts/engineer.txt index df6c372..534e39d 100644 --- a/prompts/engineer.txt +++ b/prompts/engineer.txt @@ -1,6 +1,6 @@ Prompt Engineer --- -You are {{ .Name }} ({{ .Slug }}), an expert prompt engineering specialist who designs, optimizes, and troubleshoots prompts for maximum AI effectiveness. Today is {{ .Date }}. +You are {{ .Name }} ({{ .Slug }}), an expert prompt engineering specialist who designs, optimizes, and troubleshoots prompts for maximum AI effectiveness. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`. ## Role & Expertise - **Primary Role**: Senior prompt engineer with deep knowledge of LLM behavior, cognitive architectures, and optimization techniques diff --git a/prompts/normal.txt b/prompts/normal.txt index 9be6a0d..ed11e55 100644 --- a/prompts/normal.txt +++ b/prompts/normal.txt @@ -1,6 +1,6 @@ Assistant --- -You are {{ .Name }} ({{ .Slug }}), a versatile AI assistant designed to help users accomplish diverse tasks efficiently and accurately. Today is {{ .Date }}. +You are {{ .Name }} ({{ .Slug }}), a versatile AI assistant designed to help users accomplish diverse tasks efficiently and accurately. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`. ## Core Identity & Approach - **Role**: General-purpose AI assistant with broad knowledge and problem-solving capabilities diff --git a/prompts/physics.txt b/prompts/physics.txt index a6ca3e8..f39ec8a 100644 --- a/prompts/physics.txt +++ b/prompts/physics.txt @@ -1,6 +1,6 @@ Physics Explainer --- -You are {{ .Name }} ({{ .Slug }}), a physics educator who makes complex concepts accessible without sacrificing accuracy. Today is {{ .Date }}. +You are {{ .Name }} ({{ .Slug }}), a physics educator who makes complex concepts accessible without sacrificing accuracy. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`. ## Role & Expertise - **Primary Role**: Physics educator with deep conceptual understanding and exceptional communication skills diff --git a/prompts/researcher.txt b/prompts/researcher.txt index 772cb8f..08e89c9 100644 --- a/prompts/researcher.txt +++ b/prompts/researcher.txt @@ -1,6 +1,6 @@ Research Assistant --- -You are {{ .Name }} ({{ .Slug }}), a methodical AI research specialist who conducts systematic information gathering and synthesis to provide comprehensive, evidence-based answers. Today is {{ .Date }}. +You are {{ .Name }} ({{ .Slug }}), a methodical AI research specialist who conducts systematic information gathering and synthesis to provide comprehensive, evidence-based answers. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`. ## Role & Expertise - **Primary Role**: Research methodologist skilled in systematic information gathering, source evaluation, and evidence synthesis diff --git a/prompts/reviewer.txt b/prompts/reviewer.txt index a1425d4..5e71b9f 100644 --- a/prompts/reviewer.txt +++ b/prompts/reviewer.txt @@ -1,6 +1,6 @@ Code Reviewer --- -You are {{ .Name }} ({{ .Slug }}), an expert code security and quality analyst specializing in production-ready code assessment. Today is {{ .Date }}. +You are {{ .Name }} ({{ .Slug }}), an expert code security and quality analyst specializing in production-ready code assessment. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`. ## Role & Expertise - **Primary Role**: Senior code reviewer with deep expertise in security vulnerabilities, performance optimization, and maintainable code practices diff --git a/prompts/scripts.txt b/prompts/scripts.txt index 93ff7e4..9518792 100644 --- a/prompts/scripts.txt +++ b/prompts/scripts.txt @@ -1,6 +1,6 @@ Shell Scripter --- -You are {{ .Name }} ({{ .Slug }}), an expert automation engineer specializing in robust shell scripting and system automation. Today is {{ .Date }}. +You are {{ .Name }} ({{ .Slug }}), an expert automation engineer specializing in robust shell scripting and system automation. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`. ## Role & Expertise - **Primary Role**: Senior DevOps engineer and automation specialist with deep expertise in Bash, PowerShell, and cross-platform scripting diff --git a/static/css/chat.css b/static/css/chat.css index 5a5114e..999df29 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -358,11 +358,16 @@ body:not(.loading) #loading { background: #181926; min-width: 480px; min-height: 100px; + max-width: 100%; padding: 10px 12px; - width: 100%; + width: calc(700px - 24px); border-radius: 2px; } +.message.assistant textarea.text { + width: calc(800px - 24px); +} + .message .text .error { color: #ed8796; } @@ -392,6 +397,7 @@ body:not(.loading) #loading { } .message .body { + position: relative; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; overflow: hidden; @@ -402,6 +408,21 @@ body:not(.loading) #loading { background: #24273a; } +.message.collapsed .body { + height: 32px; +} + +.message.collapsed .body::before { + position: absolute; + content: "collapsed..."; + font-style: italic; + color: #939ab7; + font-size: 12px; + top: 50%; + left: 12px; + transform: translateY(-50%); +} + .tool .call, .reasoning .toggle { position: relative; @@ -419,7 +440,7 @@ body:not(.loading) #loading { background-image: url(icons/reasoning.svg); position: absolute; top: -2px; - left: -2px; + left: 0px; width: 20px; height: 20px; } @@ -481,11 +502,11 @@ body:not(.loading) #loading { } .reasoning.expanded .toggle::after { - transform: rotate(180deg); + transform: scaleY(-100%); } .tool.expanded .call .name::after { - transform: translateY(-50%) rotate(180deg); + transform: translateY(-50%) scaleY(-100%); } .tool .call::before { @@ -528,6 +549,29 @@ body:not(.loading) #loading { pointer-events: all; } +.message .collapse { + position: relative; + margin-right: 14px; +} + +.message .collapse::before { + content: ""; + transition: 150ms; + position: absolute; + top: 0; + left: 0; +} + +.message.collapsed .collapse::before { + transform: scaleY(-100%); +} + +.message .collapse::after { + position: absolute; + top: 4px; + right: -14px; +} + .message.errored .options .copy, .message.errored .options .edit, .message.waiting .options, @@ -769,6 +813,7 @@ select { gap: 4px; } +.message .options .collapse::after, #chat .option+.option::before { content: ""; display: block; @@ -792,6 +837,8 @@ body.loading #version, .message .role::before, .message .tag-json, .message .tag-search, +.message .collapse, +.message .collapse::before, .message .copy, .message .edit, .message .retry, @@ -839,6 +886,10 @@ input.invalid { border: 1px solid #ed8796; } +.message .collapse::before { + background-image: url(icons/collapse.svg); +} + .pre-copy, .message .copy { background-image: url(icons/copy.svg); diff --git a/static/css/icons/collapse.svg b/static/css/icons/collapse.svg new file mode 100644 index 0000000..b8d8457 --- /dev/null +++ b/static/css/icons/collapse.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/js/chat.js b/static/js/chat.js index 04e2b22..7727243 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -34,6 +34,16 @@ $password = document.getElementById("password"), $login = document.getElementById("login"); + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + + let platform = ""; + + detectPlatform().then(result => { + platform = result; + + console.info(`Detected platform: ${platform}`); + }); + const messages = [], models = {}, modelList = [], @@ -143,7 +153,6 @@ #error = false; #editing = false; - #expanded = false; #state = false; #_diff; @@ -159,7 +168,7 @@ #_tool; #_statistics; - constructor(role, reasoning, text, files = []) { + constructor(role, reasoning, text, files = [], collapsed = false) { this.#id = uid(); this.#role = role; this.#reasoning = reasoning || ""; @@ -167,7 +176,7 @@ this.#_diff = document.createElement("div"); - this.#build(); + this.#build(collapsed); this.#render(); for (const file of files) { @@ -181,9 +190,9 @@ } } - #build() { + #build(collapsed) { // main message div - this.#_message = make("div", "message", this.#role); + this.#_message = make("div", "message", this.#role, collapsed ? "collapsed" : ""); // message role (wrapper) const _wrapper = make("div", "role", this.#role); @@ -224,11 +233,9 @@ _reasoning.appendChild(_toggle); _toggle.addEventListener("click", () => { - this.#expanded = !this.#expanded; + _reasoning.classList.toggle("expanded"); - _reasoning.classList.toggle("expanded", this.#expanded); - - if (this.#expanded) { + if (_reasoning.classList.contains("expanded")) { this.#updateReasoningHeight(); } @@ -303,6 +310,19 @@ this.#_message.appendChild(_opts); + // collapse option + const _optCollapse = make("button", "collapse"); + + _optCollapse.title = "Collapse/Expand message"; + + _opts.appendChild(_optCollapse); + + _optCollapse.addEventListener("click", () => { + this.#_message.classList.toggle("collapsed"); + + this.#save(); + }); + // copy option const _optCopy = make("button", "copy"); @@ -620,6 +640,10 @@ data.statistics = this.#statistics; } + if (this.#_message.classList.contains("collapsed") && full) { + data.collapsed = true; + } + if (!data.files?.length && !data.reasoning && !data.text && !data.tool) { return false; } @@ -656,7 +680,7 @@ console.error(err); if (!retrying && err.message.includes("not found")) { - setTimeout(this.loadGenerationData.bind(this), 750, generationID, true); + setTimeout(this.loadGenerationData.bind(this), 1500, generationID, true); } } } @@ -956,12 +980,18 @@ model: $model.value, temperature: temperature, iterations: iterations, + tools: { + json: jsonMode, + search: searchTool, + }, reasoning: { effort: effort, tokens: tokens || 0, }, - json: jsonMode, - search: searchTool, + metadata: { + timezone: timezone, + platform: platform, + }, messages: messages.map(message => message.getData()).filter(Boolean), }; @@ -975,7 +1005,7 @@ message.setState(false); if (!aborted) { - setTimeout(message.loadGenerationData.bind(message), 750, generationID); + setTimeout(message.loadGenerationData.bind(message), 1000, generationID); } message = null; @@ -1258,7 +1288,7 @@ } loadValue("messages", []).forEach(message => { - const obj = new Message(message.role, message.reasoning, message.text, message.files || []); + const obj = new Message(message.role, message.reasoning, message.text, message.files || [], message.collapsed); if (message.error) { obj.showError(message.error); diff --git a/static/js/lib.js b/static/js/lib.js index 333f494..2816127 100644 --- a/static/js/lib.js +++ b/static/js/lib.js @@ -45,6 +45,8 @@ function uid() { } function make(tag, ...classes) { + classes = classes.filter(Boolean); + const el = document.createElement(tag); if (classes.length) { @@ -222,3 +224,94 @@ function selectFile(accept, multiple, handler, onError = false) { input.click(); }); } + +async function detectPlatform() { + let os, arch; + + let platform = navigator.platform || ""; + + if (navigator.userAgentData?.getHighEntropyValues) { + try { + const data = await navigator.userAgentData.getHighEntropyValues(["platform", "architecture"]); + + platform = data.platform; + arch = data.architecture; + } catch {} + } + + const ua = navigator.userAgent || ""; + + // Windows + if (/Windows NT 10\.0/.test(ua)) os = "Windows 10/11"; + else if (/Windows NT 6\.3/.test(ua)) os = "Windows 8.1"; + else if (/Windows NT 6\.2/.test(ua)) os = "Windows 8"; + else if (/Windows NT 6\.1/.test(ua)) os = "Windows 7"; + else if (/Windows NT 6\.0/.test(ua)) os = "Windows Vista"; + else if (/Windows NT 5\.1/.test(ua)) os = "Windows XP"; + else if (/Windows NT 5\.0/.test(ua)) os = "Windows 2000"; + else if (/Windows NT 4\.0/.test(ua)) os = "Windows NT 4.0"; + else if (/Win(98|95|16)/.test(ua)) os = "Windows (legacy)"; + else if (/Windows/.test(ua)) os = "Windows (unknown version)"; + // Mac OS + else if (/Mac OS X/.test(ua)) { + os = "macOS"; + + const match = ua.match(/Mac OS X ([0-9_]+)/); + + if (match) { + os += ` ${match[1].replace(/_/g, ".")}`; + } else { + os += " (unknown version)"; + } + } + // Chrome OS + else if (/CrOS/.test(ua)) { + os = "Chrome OS"; + + const match = ua.match(/CrOS [^ ]+ ([0-9.]+)/); + + if (match) { + os += ` ${match[1]}`; + } + } + // Linux (special) + else if (/FreeBSD/.test(ua)) os = "FreeBSD"; + else if (/OpenBSD/.test(ua)) os = "OpenBSD"; + else if (/NetBSD/.test(ua)) os = "NetBSD"; + else if (/SunOS/.test(ua)) os = "Solaris"; + // Linux (generic) + else if (/Linux/.test(ua)) { + if (/Ubuntu/i.test(ua)) os = "Ubuntu"; + else if (/Debian/i.test(ua)) os = "Debian"; + else if (/Fedora/i.test(ua)) os = "Fedora"; + else if (/CentOS/i.test(ua)) os = "CentOS"; + else if (/Red Hat/i.test(ua)) os = "Red Hat"; + else if (/SUSE/i.test(ua)) os = "SUSE"; + else if (/Gentoo/i.test(ua)) os = "Gentoo"; + else if (/Arch/i.test(ua)) os = "Arch Linux"; + else os = "Linux"; + } + // Mobile + else if (/Android/.test(ua)) os = "Android"; + else if (/iPhone|iPad|iPod/.test(ua)) os = "iOS"; + + // We still have no OS? + if (!os && platform) { + if (platform.includes("Win")) os = "Windows"; + else if (/Mac/.test(platform)) os = "macOS"; + else if (/Linux/.test(platform)) os = "Linux"; + else os = platform; + } + + // Detect architecture + if (!arch) { + if (/WOW64|Win64|x64|amd64/i.test(ua)) arch = "x64"; + else if (/arm64|aarch64/i.test(ua)) arch = "arm64"; + else if (/i[0-9]86|x86/i.test(ua)) arch = "x86"; + else if (/ppc/i.test(ua)) arch = "ppc"; + else if (/sparc/i.test(ua)) arch = "sparc"; + else if (platform && /arm/i.test(platform)) arch = "arm"; + } + + return `${os || "Unknown OS"}${arch ? `, ${arch}` : ""}`; +}