1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-09 09:19:54 +00:00

8 Commits

Author SHA1 Message Date
Laura
f240e30dd2 tweak 2025-08-31 23:12:10 +02:00
Laura
6471aeb51b improved auto-scroll and waiting indicator 2025-08-31 23:10:14 +02:00
Laura
eeb8c22415 include links in github tool 2025-08-31 20:10:01 +02:00
Laura
8943fd83bb update readme 2025-08-31 20:04:13 +02:00
Laura
07ca59cfb1 small fix 2025-08-31 00:31:43 +02:00
Laura
703d5373f0 styling fixes 2025-08-31 00:29:45 +02:00
Laura
87d33a8d1d much improved search 2025-08-31 00:25:03 +02:00
Laura
c7a2848d05 wip 2025-08-30 15:06:49 +02:00
11 changed files with 478 additions and 154 deletions

View File

@@ -6,29 +6,38 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode
## Features ## Features
- Private and self-hosted (data stored in localStorage) ### Core Functionality
- Supports any model available on your OpenRouter account - **Private & Self-Hosted**: All your data is stored in `localStorage`.
- Real-time streaming responses - **Broad Model Support**: Use any model available on your OpenRouter account.
- Edit, delete, or copy any message - **Real-time Responses**: Get streaming responses from models as they are generated.
- Persistent settings for model, temperature, and other parameters - **Persistent Settings**: Your chosen model, temperature, and other parameters are saved between sessions.
- Full conversation control including clearing and modifying messages - **Authentication**: Optional user/password authentication for added security.
- 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 ### Conversation Control
- Easy model selection: - **Full Message Control**: Edit, delete, or copy any message in the conversation.
- Tags indicate if a model supports **tools**, **vision**, or **reasoning** - **Collapse/Expand Messages**: Collapse large messages to keep your chat history tidy.
- Search field with fuzzy matching to quickly find models - **Retry & Regenerate**: Easily retry assistant responses or regenerate from any point in the conversation.
- Models are listed newest -> oldest - **Title Generation**: Automatically generate (and refresh) a title for your chat.
- Web search tools (set the `tokens.exa` to enable): - **Import & Export**: Save and load entire chats as local JSON files.
- `search_web`: search via Exa in auto mode; returns up to 10 results with short summaries
- `fetch_contents`: fetch page contents for one or more URLs via Exa /contents ### Rich UI & UX
- `github_repository`: get a quick overview of a GitHub repository (repo info, up to 20 branches, top-level files/dirs, and the README) without cloning (optionally set `tokens.github` for higher rate limits and private repos) - **File Attachments**: Attach text, code, or images to your messages for vision-enabled models.
- Images attachments for vision models using simple markdown image tags - **Reasoning & Transparency**:
- Text/Code file attachments - View the model's thought process and tool usage in an expandable "Reasoning" section.
- Reasoning effort control - See detailed statistics for each message: provider, time-to-first-token, tokens-per-second, token count, and cost.
- Structured JSON output - Keep track of the total cost for the entire conversation.
- Statistics for messages (provider, ttft, tps, token count and cost) - **Advanced Model Search**:
- Import and export of chats as JSON files - Tags indicate if a model supports **tools**, **vision**, or **reasoning**.
- Authentication (optional) - Fuzzy matching helps you quickly find the exact model you need.
- **Smooth Interface**: Built with [morphdom](https://github.com/patrick-steele-idem/morphdom) to ensure UI updates don't lose your selections, scroll position, or focus.
### Powerful Integrated Tools
- **`search_web`**: Search the web via Exa; returns up to 12 results with short summaries.
- **`fetch_contents`**: Fetch and summarize the contents of one or more URLs.
- **`github_repository`**: Get a comprehensive overview of a GitHub repository. The tool returns:
- Core info (URL, description, stars, forks).
- A list of top-level files and directories.
- The full content of the repository's README file.
## TODO ## TODO
@@ -88,7 +97,7 @@ After a successful login, whiskr issues a signed (HMAC-SHA256) token, using the
When running behind a reverse proxy like nginx, you can have the proxy serve static files. When running behind a reverse proxy like nginx, you can have the proxy serve static files.
```ngnix ```nginx
server { server {
listen 443 ssl; listen 443 ssl;
server_name chat.example.com; server_name chat.example.com;
@@ -117,13 +126,12 @@ server {
## Usage ## Usage
- Send a message with `Ctrl+Enter` or the send button - Send a message with `Ctrl+Enter` or the send button.
- Just add a message with the add button - Hover over a message to reveal controls to **edit, delete, copy, collapse, or retry**.
- Hover over a message to edit, delete, or copy it - Click **"Reasoning"** on an assistant message to view the model's thought process or tool usage.
- Adjust model, temperature, prompt, or message role from the controls in the bottom-left - Adjust model, temperature, prompt, or message role from the controls in the bottom-left.
- Use the model search field to quickly find models (supports fuzzy matching) - Attach images using markdown syntax (`![alt](url)`) or upload text/code files with the attachment button.
- Look for tags in the model list to see if a model supports tools, vision, or reasoning - Use the buttons in the top-right to **import/export** the chat or **clear** all messages.
- Use `![alt](url)` in your message to display an image inline. If the model supports vision, the same image URL is passed to the model for multimodal input.
## License ## License

View File

@@ -145,11 +145,14 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
request.Messages = append(request.Messages, openrouter.SystemMessage(prompt)) request.Messages = append(request.Messages, openrouter.SystemMessage(prompt))
} }
if model.Tools && r.Tools.Search && env.Tokens.Exa != "" { if model.Tools && r.Tools.Search && env.Tokens.Exa != "" && r.Iterations > 1 {
request.Tools = GetSearchTools() request.Tools = GetSearchTools()
request.ToolChoice = "auto" request.ToolChoice = "auto"
request.Messages = append(request.Messages, openrouter.SystemMessage(InternalToolsPrompt)) request.Messages = append(
request.Messages,
openrouter.SystemMessage(fmt.Sprintf(InternalToolsPrompt, r.Iterations-1)),
)
} }
for _, message := range r.Messages { for _, message := range r.Messages {
@@ -273,7 +276,7 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
for iteration := range raw.Iterations { for iteration := range raw.Iterations {
debug("iteration %d of %d", iteration+1, raw.Iterations) debug("iteration %d of %d", iteration+1, raw.Iterations)
if iteration == raw.Iterations-1 { if len(request.Tools) > 0 && iteration == raw.Iterations-1 {
debug("no more tool calls") debug("no more tool calls")
request.Tools = nil request.Tools = nil

110
exa.go
View File

@@ -7,15 +7,17 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
) )
type ExaResult struct { type ExaResult struct {
Title string `json:"title"` Title string `json:"title"`
URL string `json:"url"` URL string `json:"url"`
PublishedDate string `json:"publishedDate"` PublishedDate string `json:"publishedDate,omitempty"`
SiteName string `json:"siteName,omitempty"`
Text string `json:"text"` Summary string `json:"summary,omitempty"`
Summary string `json:"summary"` Highlights []string `json:"highlights,omitempty"`
Text string `json:"text,omitempty"`
} }
type ExaCost struct { type ExaCost struct {
@@ -24,42 +26,19 @@ type ExaCost struct {
type ExaResults struct { type ExaResults struct {
RequestID string `json:"requestId"` RequestID string `json:"requestId"`
SearchType string `json:"resolvedSearchType"`
Results []ExaResult `json:"results"` Results []ExaResult `json:"results"`
Cost ExaCost `json:"costDollars"` Cost ExaCost `json:"costDollars"`
} }
func (e *ExaResult) String() string {
var (
label string
text string
)
if e.Text != "" {
label = "Text"
text = e.Text
} else if e.Summary != "" {
label = "Summary"
text = e.Summary
}
return fmt.Sprintf(
"Title: %s \nURL: %s \nPublished Date: %s \n%s: %s",
e.Title,
e.URL,
e.PublishedDate,
label,
strings.TrimSpace(text),
)
}
func (e *ExaResults) String() string { func (e *ExaResults) String() string {
list := make([]string, len(e.Results)) var builder strings.Builder
for i, result := range e.Results { json.NewEncoder(&builder).Encode(map[string]any{
list[i] = result.String() "results": e.Results,
} })
return strings.Join(list, "\n\n---\n\n") return builder.String()
} }
func NewExaRequest(ctx context.Context, path string, data any) (*http.Request, error) { func NewExaRequest(ctx context.Context, path string, data any) (*http.Request, error) {
@@ -100,15 +79,62 @@ func RunExaRequest(req *http.Request) (*ExaResults, error) {
} }
func ExaRunSearch(ctx context.Context, args SearchWebArguments) (*ExaResults, error) { func ExaRunSearch(ctx context.Context, args SearchWebArguments) (*ExaResults, error) {
if args.NumResults <= 0 {
args.NumResults = 6
} else if args.NumResults < 3 {
args.NumResults = 3
} else if args.NumResults >= 12 {
args.NumResults = 12
}
data := map[string]any{ data := map[string]any{
"query": args.Query, "query": args.Query,
"type": "auto", "type": "auto",
"numResults": args.NumResults, "numResults": args.NumResults,
"contents": map[string]any{ }
"summary": map[string]any{
"query": "Summarize this page only with all information directly relevant to answering the user's question: include key facts, numbers, dates, names, definitions, steps, code or commands, and the page's stance or conclusion; omit fluff and unrelated sections.", if len(args.Domains) > 0 {
}, data["includeDomains"] = args.Domains
}
contents := map[string]any{
"summary": map[string]any{},
"highlights": map[string]any{
"numSentences": 2,
"highlightsPerUrl": 3,
}, },
"livecrawl": "preferred",
}
switch args.Intent {
case "news":
data["category"] = "news"
data["numResults"] = max(8, args.NumResults)
data["startPublishedDate"] = daysAgo(30)
case "docs":
contents["subpages"] = 1
contents["subpageTarget"] = []string{"documentation", "changelog", "release notes"}
case "papers":
data["category"] = "research paper"
data["startPublishedDate"] = daysAgo(365 * 2)
case "code":
data["category"] = "github"
contents["subpages"] = 1
contents["subpageTarget"] = []string{"readme", "changelog", "code"}
case "deep_read":
contents["text"] = map[string]any{
"maxCharacters": 8000,
}
}
data["contents"] = contents
switch args.Recency {
case "month":
data["startPublishedDate"] = daysAgo(30)
case "year":
data["startPublishedDate"] = daysAgo(356)
} }
req, err := NewExaRequest(ctx, "/search", data) req, err := NewExaRequest(ctx, "/search", data)
@@ -122,9 +148,15 @@ func ExaRunSearch(ctx context.Context, args SearchWebArguments) (*ExaResults, er
func ExaRunContents(ctx context.Context, args FetchContentsArguments) (*ExaResults, error) { func ExaRunContents(ctx context.Context, args FetchContentsArguments) (*ExaResults, error) {
data := map[string]any{ data := map[string]any{
"urls": args.URLs, "urls": args.URLs,
"summary": map[string]any{},
"highlights": map[string]any{
"numSentences": 2,
"highlightsPerUrl": 3,
},
"text": map[string]any{ "text": map[string]any{
"maxCharacters": 8000, "maxCharacters": 8000,
}, },
"livecrawl": "preferred",
} }
req, err := NewExaRequest(ctx, "/contents", data) req, err := NewExaRequest(ctx, "/contents", data)
@@ -134,3 +166,7 @@ func ExaRunContents(ctx context.Context, args FetchContentsArguments) (*ExaResul
return RunExaRequest(req) return RunExaRequest(req)
} }
func daysAgo(days int) string {
return time.Now().Add(time.Duration(days) * 24 * time.Hour).Format(time.DateOnly)
}

View File

@@ -193,9 +193,23 @@ func RepoOverview(ctx context.Context, arguments GitHubRepositoryArguments) (str
for _, content := range contents { for _, content := range contents {
switch content.Type { switch content.Type {
case "dir": case "dir":
directories = append(directories, content.Name) directories = append(directories, fmt.Sprintf(
"[%s](https://github.com/%s/%s/tree/%s/%s)",
content.Name,
arguments.Owner,
arguments.Repo,
repository.DefaultBranch,
content.Name,
))
case "file": case "file":
files = append(files, content.Name) files = append(files, fmt.Sprintf(
"[%s](https://raw.githubusercontent.com/%s/%s/refs/heads/%s/%s)",
content.Name,
arguments.Owner,
arguments.Repo,
repository.DefaultBranch,
content.Name,
))
} }
} }

View File

@@ -1 +1,13 @@
You have access to web search tools. Use `search_web` with `query` (string) and `num_results` (1-10) to find current information - when searching for recent/latest information, always include specific dates or years (e.g., "august 2025"). Use `fetch_contents` with `urls` (array) to read full page content from search results or known URLs. Use `github_repository` with `owner` (string) and `repo` (string) to get repository overviews (info, branches, files, README) without cloning. Formulate specific, targeted queries and provide all required parameters. Call only one tool per response. # Tool use
Use at most 1 tool call per turn. You have %d turns with tool calls total. You cannot call multiple tools at once. If you have multiple turns you can call more tools in later turns. Match the tool arguments exactly. Prefer using `github_repository` over `fetch_contents` to get github repository information.
search_web({query, num_results?, intent?, recency?, domains?})
- Fresh info & citations. Keep query short; add month/year if freshness matters.
- intent: auto|news|docs|papers|code|deep_read (deep_read may include full text).
- num_results: default 6 (3-12); recency: auto|month|year.
fetch_contents({urls})
- Read 1-5 given URLs for exact content or quotes/numbers.
github_repository({owner,repo})
- Quick repo overview + README excerpt.

View File

@@ -12,7 +12,10 @@ import (
type SearchWebArguments struct { type SearchWebArguments struct {
Query string `json:"query"` Query string `json:"query"`
NumResults int `json:"num_results"` NumResults int `json:"num_results,omitempty"`
Intent string `json:"intent,omitempty"`
Recency string `json:"recency,omitempty"`
Domains []string `json:"domains,omitempty"`
} }
type FetchContentsArguments struct { type FetchContentsArguments struct {
@@ -30,40 +33,60 @@ func GetSearchTools() []openrouter.Tool {
Type: openrouter.ToolTypeFunction, Type: openrouter.ToolTypeFunction,
Function: &openrouter.FunctionDefinition{ Function: &openrouter.FunctionDefinition{
Name: "search_web", Name: "search_web",
Description: "Search the web via Exa in auto mode. Returns up to 10 results with short summaries.", Description: "Search the live web (via Exa /search) and return summaries, highlights, and optionally full text for the top results.",
Parameters: map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
"required": []string{"query", "num_results"}, "required": []string{"query"},
"properties": map[string]any{ "properties": map[string]any{
"query": map[string]any{ "query": map[string]any{
"type": "string", "type": "string",
"description": "A concise, specific search query in natural language.", "description": "A concise, specific search query in natural language. Include month/year if recency matters (e.g., 'august 2025').",
}, },
"num_results": map[string]any{ "num_results": map[string]any{
"type": "integer", "type": "integer",
"description": "Number of results to return (3-10). Default to 6.", "description": "Number of results to return (3-12). Default is 6.",
"minimum": 3, "minimum": 3,
"maximum": 10, "maximum": 10,
}, },
"intent": map[string]any{
"type": "string",
"enum": []string{"auto", "news", "docs", "papers", "code", "deep_read"},
"description": "Search profile. Use 'news' for breaking topics, 'docs' for official docs/changelogs, 'papers' for research, 'code' for repos, 'deep_read' when you need exact quotes/numbers (adds full text). Default 'auto'.",
},
"recency": map[string]any{
"type": "string",
"enum": []string{"auto", "month", "year", "range"},
"description": "Time filter hint. 'month' ~ last 30 days, 'year' ~ last 365 days. Default 'auto'.",
},
"domains": map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
"description": "Restrict to these domains (e.g., ['europa.eu', 'who.int']).",
},
}, },
"additionalProperties": false, "additionalProperties": false,
}, },
Strict: true,
}, },
}, },
{ {
Type: openrouter.ToolTypeFunction, Type: openrouter.ToolTypeFunction,
Function: &openrouter.FunctionDefinition{ Function: &openrouter.FunctionDefinition{
Name: "fetch_contents", Name: "fetch_contents",
Description: "Fetch page contents for one or more URLs via Exa /contents.", Description: "Fetch and summarize page contents for one or more URLs (via Exa /contents). Use when the user provides specific links.",
Parameters: map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
"required": []string{"urls"}, "required": []string{"urls"},
"properties": map[string]any{ "properties": map[string]any{
"urls": map[string]any{ "urls": map[string]any{
"type": "array", "type": "array",
"description": "List of URLs (1..N) to fetch.", "description": "List of URLs to fetch.",
"items": map[string]any{"type": "string"}, "items": map[string]any{
"type": "string",
},
"minItems": 1,
"maxItems": 5,
}, },
}, },
"additionalProperties": false, "additionalProperties": false,
@@ -75,14 +98,14 @@ func GetSearchTools() []openrouter.Tool {
Type: openrouter.ToolTypeFunction, Type: openrouter.ToolTypeFunction,
Function: &openrouter.FunctionDefinition{ Function: &openrouter.FunctionDefinition{
Name: "github_repository", Name: "github_repository",
Description: "Get a quick overview of a GitHub repository without cloning: repo info, up to 20 branches (popular first), top-level files/dirs, and the README.", Description: "Fetch repository metadata and README from GitHub.",
Parameters: map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
"required": []string{"owner", "repo"}, "required": []string{"owner", "repo"},
"properties": map[string]any{ "properties": map[string]any{
"owner": map[string]any{ "owner": map[string]any{
"type": "string", "type": "string",
"description": "GitHub username or organization (e.g., 'torvalds').", "description": "Repository owner (e.g., 'torvalds').",
}, },
"repo": map[string]any{ "repo": map[string]any{
"type": "string", "type": "string",
@@ -100,7 +123,7 @@ func GetSearchTools() []openrouter.Tool {
func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error { func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error {
var arguments SearchWebArguments var arguments SearchWebArguments
err := json.Unmarshal([]byte(tool.Args), &arguments) err := ParseAndUpdateArgs(tool, &arguments)
if err != nil { if err != nil {
return err return err
} }
@@ -132,7 +155,7 @@ func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error {
func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error { func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error {
var arguments FetchContentsArguments var arguments FetchContentsArguments
err := json.Unmarshal([]byte(tool.Args), &arguments) err := ParseAndUpdateArgs(tool, &arguments)
if err != nil { if err != nil {
return err return err
} }
@@ -164,7 +187,7 @@ func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error {
func HandleGitHubRepositoryTool(ctx context.Context, tool *ToolCall) error { func HandleGitHubRepositoryTool(ctx context.Context, tool *ToolCall) error {
var arguments GitHubRepositoryArguments var arguments GitHubRepositoryArguments
err := json.Unmarshal([]byte(tool.Args), &arguments) err := ParseAndUpdateArgs(tool, &arguments)
if err != nil { if err != nil {
return err return err
} }
@@ -180,3 +203,19 @@ func HandleGitHubRepositoryTool(ctx context.Context, tool *ToolCall) error {
return nil return nil
} }
func ParseAndUpdateArgs(tool *ToolCall, arguments any) error {
err := json.Unmarshal([]byte(tool.Args), arguments)
if err != nil {
return err
}
b, err := json.Marshal(arguments)
if err != nil {
return err
}
tool.Args = string(b)
return nil
}

View File

@@ -221,9 +221,23 @@ body:not(.loading) #loading {
} }
#title.refreshing #title-text { #title.refreshing #title-text {
position: relative;
filter: blur(3px); filter: blur(3px);
} }
#title.refreshing #title-text::before {
content: "";
position: absolute;
width: 38px;
height: 100%;
top: 0;
left: 0;
animation: swivel 1.2s ease-in-out infinite;
background: #6e738d;
opacity: 0.5;
border-radius: 6px;
}
#messages { #messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -320,6 +334,7 @@ body:not(.loading) #loading {
margin-left: 12px; margin-left: 12px;
} }
.message:not(.has-text) .text,
.message:not(.has-tags) .tags { .message:not(.has-tags) .tags {
display: none; display: none;
} }
@@ -378,10 +393,15 @@ body:not(.loading) #loading {
border: 2px solid #ed8796; border: 2px solid #ed8796;
} }
.tool .result pre,
.reasoning-text pre { .reasoning-text pre {
background: #1b1d2a; background: #1b1d2a;
} }
.tool .result pre.l-json .pre-header {
display: none;
}
.message .tool .result, .message .tool .result,
.message .reasoning-text { .message .reasoning-text {
background: #1e2030; background: #1e2030;
@@ -390,6 +410,14 @@ body:not(.loading) #loading {
margin-top: 16px; margin-top: 16px;
} }
.reasoning-text strong {
display: block;
}
.reasoning-text strong:not(:first-child) {
margin-top: 14px;
}
.message.has-reasoning:not(.has-text):not(.errored) div.text, .message.has-reasoning:not(.has-text):not(.errored) div.text,
.message.has-tool:not(.has-text):not(.errored) div.text, .message.has-tool:not(.has-text):not(.errored) div.text,
.message.has-files:not(.has-text):not(.errored) div.text, .message.has-files:not(.has-text):not(.errored) div.text,
@@ -606,9 +634,30 @@ body:not(.loading) #loading {
font-style: italic; font-style: italic;
} }
.message:empty.receiving .text::before, .message .loader {
.message.waiting .text::before { display: none;
content: ". . ."; }
.message.receiving:not(.has-text) .loader,
.message.waiting .loader {
display: flex;
gap: 8px;
}
.message .loader span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #939ab7;
animation: bounce 1.4s infinite ease-in-out;
}
.message .loader span:nth-child(2) {
animation-delay: 0.2s;
}
.message .loader span:nth-child(3) {
animation-delay: 0.4s;
} }
.statistics { .statistics {
@@ -705,6 +754,10 @@ body:not(.loading) #loading {
cursor: n-resize; cursor: n-resize;
} }
#chat:has(.has-files) #resize-bar {
top: 46px;
}
#attachments { #attachments {
position: absolute; position: absolute;
top: 2px; top: 2px;
@@ -1030,6 +1083,10 @@ label[for="reasoning-tokens"] {
background-image: url(icons/screen.svg); background-image: url(icons/screen.svg);
} }
#scrolling.not-following {
opacity: 0.5;
}
#json { #json {
background-image: url(icons/json-off.svg); background-image: url(icons/json-off.svg);
} }
@@ -1190,6 +1247,32 @@ label[for="reasoning-tokens"] {
background: #89bb77; background: #89bb77;
} }
@keyframes swivel {
0%,
100% {
left: 0px;
}
50% {
left: calc(100%);
transform: translateX(-100%);
}
}
@keyframes bounce {
0%,
75%,
100% {
transform: translateY(0);
}
25% {
transform: translateY(-4px);
}
}
@keyframes wiggling { @keyframes wiggling {
0% { 0% {
transform: translate(0px); transform: translate(0px);

View File

@@ -33,7 +33,7 @@
<div id="title-text"></div> <div id="title-text"></div>
</div> </div>
<div id="messages"></div> <div id="messages" tabindex="0"></div>
<div id="chat"> <div id="chat">
<button id="top" class="hidden" title="Scroll to top"></button> <button id="top" class="hidden" title="Scroll to top"></button>

View File

@@ -1,7 +1,6 @@
(() => { (() => {
const $version = document.getElementById("version"), const $version = document.getElementById("version"),
$total = document.getElementById("total"), $total = document.getElementById("total"),
$notifications = document.getElementById("notifications"),
$title = document.getElementById("title"), $title = document.getElementById("title"),
$titleRefresh = document.getElementById("title-refresh"), $titleRefresh = document.getElementById("title-refresh"),
$titleText = document.getElementById("title-text"), $titleText = document.getElementById("title-text"),
@@ -34,7 +33,8 @@
$password = document.getElementById("password"), $password = document.getElementById("password"),
$login = document.getElementById("login"); $login = document.getElementById("login");
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; const nearBottom = 22,
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
let platform = ""; let platform = "";
@@ -50,6 +50,8 @@
promptList = []; promptList = [];
let autoScrolling = false, let autoScrolling = false,
followTail = true,
awaitingScroll = false,
jsonMode = false, jsonMode = false,
searchTool = false, searchTool = false,
chatTitle = false; chatTitle = false;
@@ -66,34 +68,6 @@
$total.textContent = formatMoney(totalCost); $total.textContent = formatMoney(totalCost);
} }
async function notify(msg, persistent = false) {
console.warn(msg);
const notification = make("div", "notification", "off-screen");
notification.textContent = msg instanceof Error ? msg.message : msg;
$notifications.appendChild(notification);
await wait(250);
notification.classList.remove("off-screen");
if (persistent) {
return;
}
await wait(5000);
notification.style.height = `${notification.getBoundingClientRect().height}px`;
notification.classList.add("off-screen");
await wait(250);
notification.remove();
}
function updateTitle() { function updateTitle() {
const title = chatTitle || (messages.length ? "New Chat" : ""); const title = chatTitle || (messages.length ? "New Chat" : "");
@@ -106,26 +80,44 @@
storeValue("title", chatTitle); storeValue("title", chatTitle);
} }
function distanceFromBottom() {
return $messages.scrollHeight - ($messages.scrollTop + $messages.clientHeight);
}
function updateScrollButton() { function updateScrollButton() {
const bottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight); const bottom = distanceFromBottom();
$top.classList.toggle("hidden", $messages.scrollTop < 80); $top.classList.toggle("hidden", $messages.scrollTop < 80);
$bottom.classList.toggle("hidden", bottom < 80); $bottom.classList.toggle("hidden", bottom < 80);
} }
function setFollowTail(follow) {
followTail = follow;
$scrolling.classList.toggle("not-following", !followTail);
}
function scroll(force = false, instant = false) { function scroll(force = false, instant = false) {
if (!autoScrolling && !force) { if (awaitingScroll || !(followTail || force)) {
updateScrollButton(); updateScrollButton();
return; return;
} }
setTimeout(() => { awaitingScroll = true;
requestAnimationFrame(() => {
awaitingScroll = false;
if (!followTail && !force) {
return;
}
$messages.scroll({ $messages.scroll({
top: $messages.scrollHeight, top: $messages.scrollHeight,
behavior: instant ? "instant" : "smooth", behavior: instant ? "instant" : "smooth",
}); });
}, 0); });
} }
function preloadIcons(icons) { function preloadIcons(icons) {
@@ -215,6 +207,13 @@
this.#_message.appendChild(_body); this.#_message.appendChild(_body);
// loader
const _loader = make("div", "loader");
_loader.innerHTML = "<span></span>".repeat(3);
_body.appendChild(_loader);
// message files // message files
this.#_files = make("div", "files"); this.#_files = make("div", "files");
@@ -233,12 +232,14 @@
_reasoning.appendChild(_toggle); _reasoning.appendChild(_toggle);
_toggle.addEventListener("click", () => { _toggle.addEventListener("click", () => {
_reasoning.classList.toggle("expanded"); let delta = this.#updateReasoningHeight() + 16; // margin
if (_reasoning.classList.contains("expanded")) { if (!_reasoning.classList.toggle("expanded")) {
this.#updateReasoningHeight(); delta = -delta;
} }
setFollowTail(distanceFromBottom() + delta <= nearBottom);
updateScrollButton(); updateScrollButton();
}); });
@@ -282,7 +283,13 @@
this.#_tool.appendChild(_call); this.#_tool.appendChild(_call);
_call.addEventListener("click", () => { _call.addEventListener("click", () => {
this.#_tool.classList.toggle("expanded"); let delta = this.#updateToolHeight() + 16; // margin
if (!this.#_tool.classList.toggle("expanded")) {
delta = -delta;
}
setFollowTail(distanceFromBottom() + delta <= nearBottom);
updateScrollButton(); updateScrollButton();
}); });
@@ -324,6 +331,10 @@
_optCollapse.addEventListener("click", () => { _optCollapse.addEventListener("click", () => {
this.#_message.classList.toggle("collapsed"); this.#_message.classList.toggle("collapsed");
updateScrollButton();
setFollowTail(distanceFromBottom() <= nearBottom);
this.#save(); this.#save();
}); });
@@ -432,13 +443,20 @@
} }
#updateReasoningHeight() { #updateReasoningHeight() {
this.#_reasoning.parentNode.style.setProperty("--height", `${this.#_reasoning.scrollHeight}px`); const height = this.#_reasoning.scrollHeight;
this.#_reasoning.parentNode.style.setProperty("--height", `${height}px`);
return height;
} }
#updateToolHeight() { #updateToolHeight() {
const result = this.#_tool.querySelector(".result"); const result = this.#_tool.querySelector(".result"),
height = result.scrollHeight;
this.#_tool.style.setProperty("--height", `${result.scrollHeight}px`); this.#_tool.style.setProperty("--height", `${height}px`);
return height;
} }
#morph(from, to) { #morph(from, to) {
@@ -513,7 +531,7 @@
_cost.textContent = cost ? `${formatMoney(cost)}` : ""; _cost.textContent = cost ? `${formatMoney(cost)}` : "";
_result.classList.toggle("error", result?.startsWith("error: ")); _result.classList.toggle("error", result?.startsWith("error: "));
_result.innerHTML = render(result || "*processing*"); _result.innerHTML = render(result ? wrapJSON(result) : "*processing*");
this.#_tool.classList.toggle("invalid", !!invalid); this.#_tool.classList.toggle("invalid", !!invalid);
@@ -524,8 +542,6 @@
this.#_message.classList.toggle("has-tool", !!this.#tool); this.#_message.classList.toggle("has-tool", !!this.#tool);
this.#updateToolHeight();
noScroll || scroll(); noScroll || scroll();
updateScrollButton(); updateScrollButton();
@@ -565,8 +581,6 @@
if (!only || only === "reasoning") { if (!only || only === "reasoning") {
this.#patch("reasoning", this.#_reasoning, this.#reasoning, () => { this.#patch("reasoning", this.#_reasoning, this.#reasoning, () => {
this.#updateReasoningHeight();
noScroll || scroll(); noScroll || scroll();
updateScrollButton(); updateScrollButton();
@@ -829,6 +843,10 @@
this.#render(false, true); this.#render(false, true);
this.#save(); this.#save();
} }
setFollowTail(distanceFromBottom() <= nearBottom);
updateScrollButton();
} }
delete() { delete() {
@@ -951,6 +969,10 @@
} }
} }
if (autoScrolling) {
setFollowTail(true);
}
let temperature = parseFloat($temperature.value); let temperature = parseFloat($temperature.value);
if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) { if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) {
@@ -1308,7 +1330,9 @@
} }
if (message.tags) { if (message.tags) {
message.tags.forEach(tag => obj.addTag(tag)); message.tags.forEach(tag => {
obj.addTag(tag);
});
} }
if (message.tool) { if (message.tool) {
@@ -1324,10 +1348,9 @@
updateTitle(); updateTitle();
scroll(); requestAnimationFrame(() => {
$messages.scrollTop = $messages.scrollHeight;
// small fix, sometimes when hard reloading we don't scroll all the way });
setTimeout(scroll, 250);
} }
let attachments = []; let attachments = [];
@@ -1437,7 +1460,17 @@
updateScrollButton(); updateScrollButton();
}); });
$messages.addEventListener("wheel", event => {
if (event.deltaY < 0) {
setFollowTail(false);
} else {
setFollowTail(distanceFromBottom() - event.deltaY <= nearBottom);
}
});
$bottom.addEventListener("click", () => { $bottom.addEventListener("click", () => {
setFollowTail(true);
$messages.scroll({ $messages.scroll({
top: $messages.scrollHeight, top: $messages.scrollHeight,
behavior: "smooth", behavior: "smooth",
@@ -1445,6 +1478,8 @@
}); });
$top.addEventListener("click", () => { $top.addEventListener("click", () => {
setFollowTail($messages.scrollHeight <= $messages.clientHeight);
$messages.scroll({ $messages.scroll({
top: 0, top: 0,
behavior: "smooth", behavior: "smooth",
@@ -1452,7 +1487,7 @@
}); });
$resizeBar.addEventListener("mousedown", event => { $resizeBar.addEventListener("mousedown", event => {
const isAtBottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight) <= 10; const isAtBottom = $messages.scrollHeight - ($messages.scrollTop + $messages.clientHeight) <= 10;
if (event.button === 1) { if (event.button === 1) {
$chat.style.height = ""; $chat.style.height = "";
@@ -1671,6 +1706,8 @@
autoScrolling = !autoScrolling; autoScrolling = !autoScrolling;
if (autoScrolling) { if (autoScrolling) {
setFollowTail(true);
$scrolling.title = "Turn off auto-scrolling"; $scrolling.title = "Turn off auto-scrolling";
$scrolling.classList.add("on"); $scrolling.classList.add("on");
@@ -1729,7 +1766,7 @@
} }
const total = window.innerHeight, const total = window.innerHeight,
height = clamp(window.innerHeight - event.clientY, 100, total - 240); height = clamp(window.innerHeight - event.clientY + (attachments.length ? 50 : 0), 100, total - 240);
$chat.style.height = `${height}px`; $chat.style.height = `${height}px`;
@@ -1744,6 +1781,52 @@
document.body.classList.remove("resizing"); document.body.classList.remove("resizing");
}); });
addEventListener("keydown", event => {
if (["TEXTAREA", "INPUT", "SELECT"].includes(document.activeElement?.tagName)) {
return;
}
let delta;
switch (event.key) {
case "PageUp":
case "ArrowUp":
delta = event.key === "PageUp" ? -$messages.clientHeight : -120;
setFollowTail(false);
break;
case "PageDown":
case "ArrowDown":
delta = event.key === "PageDown" ? $messages.clientHeight : 120;
setFollowTail(distanceFromBottom() - delta <= nearBottom);
break;
case "Home":
delta = -$messages.scrollTop;
setFollowTail(false);
break;
case "End":
delta = $messages.scrollHeight - $messages.clientHeight - $messages.scrollTop;
setFollowTail(true);
break;
}
if (delta) {
event.preventDefault();
$messages.scrollBy({
top: delta,
behavior: "smooth",
});
}
});
dropdown($role); dropdown($role);
dropdown($reasoningEffort); dropdown($reasoningEffort);

View File

@@ -114,6 +114,20 @@ function clamp(num, min, max) {
return Math.min(Math.max(num, min), max); return Math.min(Math.max(num, min), max);
} }
function wrapJSON(txt) {
if (!txt || !txt.startsWith("{")) {
return txt;
}
try {
const data = JSON.parse(txt);
return `\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\``;
} catch {}
return txt;
}
function download(name, type, data) { function download(name, type, data) {
let blob; let blob;
@@ -315,3 +329,35 @@ async function detectPlatform() {
return `${os || "Unknown OS"}${arch ? `, ${arch}` : ""}`; return `${os || "Unknown OS"}${arch ? `, ${arch}` : ""}`;
} }
(() => {
const $notifications = document.getElementById("notifications");
window.notify = async (msg, persistent = false) => {
console.warn(msg);
const notification = make("div", "notification", "off-screen");
notification.textContent = msg instanceof Error ? msg.message : msg;
$notifications.appendChild(notification);
await wait(250);
notification.classList.remove("off-screen");
if (persistent) {
return;
}
await wait(5000);
notification.style.height = `${notification.getBoundingClientRect().height}px`;
notification.classList.add("off-screen");
await wait(250);
notification.remove();
};
})();

View File

@@ -41,7 +41,7 @@
code(code) { code(code) {
const header = `<div class="pre-header">${escapeHtml(code.lang)}<button class="pre-copy" title="Copy code contents"></button></div>`; const header = `<div class="pre-header">${escapeHtml(code.lang)}<button class="pre-copy" title="Copy code contents"></button></div>`;
return `<pre>${header}<code>${code.text}</code></pre>`; return `<pre class="l-${escapeHtml(code.lang)}">${header}<code>${code.text}</code></pre>`;
}, },
link(link) { link(link) {