mirror of
https://github.com/coalaura/whiskr.git
synced 2025-09-08 17:06:42 +00:00
Compare commits
20 Commits
be17a801f8
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f18e9e577e | ||
![]() |
ce9813a331 | ||
![]() |
aeed519df0 | ||
![]() |
a819ec7b38 | ||
![]() |
db138324fa | ||
![]() |
2d65c2f484 | ||
![]() |
d903521154 | ||
![]() |
30755706fb | ||
![]() |
f240e30dd2 | ||
![]() |
6471aeb51b | ||
![]() |
eeb8c22415 | ||
![]() |
8943fd83bb | ||
![]() |
07ca59cfb1 | ||
![]() |
703d5373f0 | ||
![]() |
87d33a8d1d | ||
![]() |
c7a2848d05 | ||
![]() |
118e88ab67 | ||
![]() |
dc8ad8d408 | ||
![]() |
7abfd965db | ||
![]() |
fc0a34ee12 |
74
README.md
74
README.md
@@ -6,32 +6,43 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode
|
||||
|
||||
## Features
|
||||
|
||||
- Private and self-hosted (data stored in localStorage)
|
||||
- Supports any model available on your OpenRouter account
|
||||
- Real-time streaming responses
|
||||
- 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**
|
||||
- Search field with fuzzy matching to quickly find models
|
||||
- Models are listed newest -> oldest
|
||||
- Web search tools (set the `tokens.exa` to enable):
|
||||
- `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
|
||||
- `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)
|
||||
- Images attachments for vision models using simple markdown image tags
|
||||
- Text/Code file attachments
|
||||
- Reasoning effort control
|
||||
- Structured JSON output
|
||||
- Statistics for messages (provider, ttft, tps, token count and cost)
|
||||
- Import and export of chats as JSON files
|
||||
- Authentication (optional)
|
||||
### Core Functionality
|
||||
- **Private & Self-Hosted**: All your data is stored in `localStorage`.
|
||||
- **Broad Model Support**: Use any model available on your OpenRouter account.
|
||||
- **Real-time Responses**: Get streaming responses from models as they are generated.
|
||||
- **Persistent Settings**: Your chosen model, temperature, and other parameters are saved between sessions.
|
||||
- **Authentication**: Optional user/password authentication for added security.
|
||||
|
||||
### Conversation Control
|
||||
- **Full Message Control**: Edit, delete, or copy any message in the conversation.
|
||||
- **Collapse/Expand Messages**: Collapse large messages to keep your chat history tidy.
|
||||
- **Retry & Regenerate**: Easily retry assistant responses or regenerate from any point in the conversation.
|
||||
- **Title Generation**: Automatically generate (and refresh) a title for your chat.
|
||||
- **Import & Export**: Save and load entire chats as local JSON files.
|
||||
|
||||
### Rich UI & UX
|
||||
- **File Attachments**: Attach text, code, or images to your messages for vision-enabled models.
|
||||
- **Reasoning & Transparency**:
|
||||
- View the model's thought process and tool usage in an expandable "Reasoning" section.
|
||||
- See detailed statistics for each message: provider, time-to-first-token, tokens-per-second, token count, and cost.
|
||||
- Keep track of the total cost for the entire conversation.
|
||||
- **Advanced Model Search**:
|
||||
- Tags indicate if a model supports **tools**, **vision**, or **reasoning**.
|
||||
- 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
|
||||
|
||||
- restrict model list (optionally allow all for authenticated users)
|
||||
- make authentication optional (unless no allowed models)
|
||||
- improved custom prompts
|
||||
- settings
|
||||
- auto-retry on edit
|
||||
@@ -88,7 +99,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.
|
||||
|
||||
```ngnix
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name chat.example.com;
|
||||
@@ -117,14 +128,13 @@ server {
|
||||
|
||||
## Usage
|
||||
|
||||
- Send a message with `Ctrl+Enter` or the send button
|
||||
- Just add a message with the add button
|
||||
- Hover over a message to edit, delete, or copy it
|
||||
- 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)
|
||||
- Look for tags in the model list to see if a model supports tools, vision, or reasoning
|
||||
- Use `` 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.
|
||||
- Send a message with `Ctrl+Enter` or the send button.
|
||||
- Hover over a message to reveal controls to **edit, delete, copy, collapse, or retry**.
|
||||
- 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.
|
||||
- Attach images using markdown syntax (``) or upload text/code files with the attachment button.
|
||||
- Use the buttons in the top-right to **import/export** the chat or **clear** all messages.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 see [LICENSE](LICENSE) for details.
|
||||
GPL-3.0 see [LICENSE](LICENSE) for details.
|
25
chat.go
25
chat.go
@@ -145,11 +145,14 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
|
||||
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.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 {
|
||||
@@ -273,7 +276,9 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||
for iteration := range raw.Iterations {
|
||||
debug("iteration %d of %d", iteration+1, raw.Iterations)
|
||||
|
||||
if iteration == raw.Iterations-1 {
|
||||
response.Send(StartChunk())
|
||||
|
||||
if len(request.Tools) > 0 && iteration == raw.Iterations-1 {
|
||||
debug("no more tool calls")
|
||||
|
||||
request.Tools = nil
|
||||
@@ -338,6 +343,8 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||
tool.AsAssistantToolCall(message),
|
||||
tool.AsToolMessage(),
|
||||
)
|
||||
|
||||
response.Send(EndChunk())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,11 +357,13 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch
|
||||
defer stream.Close()
|
||||
|
||||
var (
|
||||
id string
|
||||
result strings.Builder
|
||||
tool *ToolCall
|
||||
id string
|
||||
tool *ToolCall
|
||||
)
|
||||
|
||||
buf := GetFreeBuffer()
|
||||
defer pool.Put(buf)
|
||||
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
@@ -402,7 +411,7 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch
|
||||
content := choice.Delta.Content
|
||||
|
||||
if content != "" {
|
||||
result.WriteString(content)
|
||||
buf.WriteString(content)
|
||||
|
||||
response.Send(TextChunk(content))
|
||||
} else if choice.Delta.Reasoning != nil {
|
||||
@@ -410,7 +419,7 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch
|
||||
}
|
||||
}
|
||||
|
||||
return tool, result.String(), nil
|
||||
return tool, buf.String(), nil
|
||||
}
|
||||
|
||||
func SplitImagePairs(text string) []openrouter.ChatMessagePart {
|
||||
|
124
exa.go
124
exa.go
@@ -6,16 +6,17 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExaResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
PublishedDate string `json:"publishedDate"`
|
||||
|
||||
Text string `json:"text"`
|
||||
Summary string `json:"summary"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
PublishedDate string `json:"publishedDate,omitempty"`
|
||||
SiteName string `json:"siteName,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Highlights []string `json:"highlights,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type ExaCost struct {
|
||||
@@ -23,43 +24,21 @@ type ExaCost struct {
|
||||
}
|
||||
|
||||
type ExaResults struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Results []ExaResult `json:"results"`
|
||||
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),
|
||||
)
|
||||
RequestID string `json:"requestId"`
|
||||
SearchType string `json:"resolvedSearchType"`
|
||||
Results []ExaResult `json:"results"`
|
||||
Cost ExaCost `json:"costDollars"`
|
||||
}
|
||||
|
||||
func (e *ExaResults) String() string {
|
||||
list := make([]string, len(e.Results))
|
||||
buf := GetFreeBuffer()
|
||||
defer pool.Put(buf)
|
||||
|
||||
for i, result := range e.Results {
|
||||
list[i] = result.String()
|
||||
}
|
||||
json.NewEncoder(buf).Encode(map[string]any{
|
||||
"results": e.Results,
|
||||
})
|
||||
|
||||
return strings.Join(list, "\n\n---\n\n")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
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) {
|
||||
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{
|
||||
"query": args.Query,
|
||||
"type": "auto",
|
||||
"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)
|
||||
@@ -121,10 +147,16 @@ func ExaRunSearch(ctx context.Context, args SearchWebArguments) (*ExaResults, er
|
||||
|
||||
func ExaRunContents(ctx context.Context, args FetchContentsArguments) (*ExaResults, error) {
|
||||
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{
|
||||
"maxCharacters": 8000,
|
||||
},
|
||||
"livecrawl": "preferred",
|
||||
}
|
||||
|
||||
req, err := NewExaRequest(ctx, "/contents", data)
|
||||
@@ -134,3 +166,7 @@ func ExaRunContents(ctx context.Context, args FetchContentsArguments) (*ExaResul
|
||||
|
||||
return RunExaRequest(req)
|
||||
}
|
||||
|
||||
func daysAgo(days int) string {
|
||||
return time.Now().Add(time.Duration(days) * 24 * time.Hour).Format(time.DateOnly)
|
||||
}
|
||||
|
47
github.go
47
github.go
@@ -193,9 +193,23 @@ func RepoOverview(ctx context.Context, arguments GitHubRepositoryArguments) (str
|
||||
for _, content := range contents {
|
||||
switch content.Type {
|
||||
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":
|
||||
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,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,35 +220,36 @@ func RepoOverview(ctx context.Context, arguments GitHubRepositoryArguments) (str
|
||||
// wait and combine results
|
||||
wg.Wait()
|
||||
|
||||
var builder strings.Builder
|
||||
buf := GetFreeBuffer()
|
||||
defer pool.Put(buf)
|
||||
|
||||
fmt.Fprintf(&builder, "### %s (%s)\n", repository.Name, repository.Visibility)
|
||||
fmt.Fprintf(&builder, "- URL: %s\n", repository.HtmlURL)
|
||||
fmt.Fprintf(&builder, "- Description: %s\n", strings.ReplaceAll(repository.Description, "\n", " "))
|
||||
fmt.Fprintf(&builder, "- Default branch: %s\n", repository.DefaultBranch)
|
||||
fmt.Fprintf(&builder, "- Stars: %d | Forks: %d\n", repository.Stargazers, repository.Forks)
|
||||
fmt.Fprintf(buf, "### %s (%s)\n", repository.Name, repository.Visibility)
|
||||
fmt.Fprintf(buf, "- URL: %s\n", repository.HtmlURL)
|
||||
fmt.Fprintf(buf, "- Description: %s\n", strings.ReplaceAll(repository.Description, "\n", " "))
|
||||
fmt.Fprintf(buf, "- Default branch: %s\n", repository.DefaultBranch)
|
||||
fmt.Fprintf(buf, "- Stars: %d | Forks: %d\n", repository.Stargazers, repository.Forks)
|
||||
|
||||
builder.WriteString("\n### Top-level files and directories\n")
|
||||
buf.WriteString("\n### Top-level files and directories\n")
|
||||
|
||||
if len(directories) == 0 && len(files) == 0 {
|
||||
builder.WriteString("*No entries or insufficient permissions.*\n")
|
||||
buf.WriteString("*No entries or insufficient permissions.*\n")
|
||||
} else {
|
||||
for _, directory := range directories {
|
||||
fmt.Fprintf(&builder, "- [D] %s\n", directory)
|
||||
fmt.Fprintf(buf, "- [D] %s\n", directory)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
fmt.Fprintf(&builder, "- [F] %s\n", file)
|
||||
fmt.Fprintf(buf, "- [F] %s\n", file)
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteString("\n### README\n")
|
||||
buf.WriteString("\n### README\n")
|
||||
|
||||
if readmeMarkdown == "" {
|
||||
builder.WriteString("*No README found or could not load.*\n")
|
||||
buf.WriteString("*No README found or could not load.*\n")
|
||||
} else {
|
||||
builder.WriteString(readmeMarkdown)
|
||||
buf.WriteString(readmeMarkdown)
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
8
go.mod
8
go.mod
@@ -6,8 +6,8 @@ require (
|
||||
github.com/coalaura/plain v0.2.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/revrost/go-openrouter v0.2.2
|
||||
golang.org/x/crypto v0.41.0
|
||||
github.com/revrost/go-openrouter v0.2.3
|
||||
golang.org/x/crypto v0.42.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -16,6 +16,6 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/term v0.34.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
)
|
||||
|
10
go.sum
10
go.sum
@@ -7,8 +7,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
@@ -26,6 +24,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/revrost/go-openrouter v0.2.2 h1:7bOdLPKmw0iJB1AdpN+YaWUd2XC9cwfJKDY10iaSAzI=
|
||||
github.com/revrost/go-openrouter v0.2.2/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0=
|
||||
github.com/revrost/go-openrouter v0.2.3 h1:ollIaPrgVWgqJyKbJGSX1jFs66eAWJs8Ojrxnd2i/E0=
|
||||
github.com/revrost/go-openrouter v0.2.3/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
@@ -33,13 +33,19 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
@@ -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.
|
@@ -134,9 +134,10 @@ func BuildPrompt(name string, metadata Metadata, model *Model) (string, error) {
|
||||
metadata.Platform = "Unknown"
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf := GetFreeBuffer()
|
||||
defer pool.Put(buf)
|
||||
|
||||
err := tmpl.Execute(&buf, PromptData{
|
||||
err := tmpl.Execute(buf, PromptData{
|
||||
Name: model.Name,
|
||||
Slug: model.ID,
|
||||
Date: time.Now().In(tz).Format(time.RFC1123),
|
||||
|
75
search.go
75
search.go
@@ -11,8 +11,11 @@ import (
|
||||
)
|
||||
|
||||
type SearchWebArguments struct {
|
||||
Query string `json:"query"`
|
||||
NumResults int `json:"num_results"`
|
||||
Query string `json:"query"`
|
||||
NumResults int `json:"num_results,omitempty"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Recency string `json:"recency,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
}
|
||||
|
||||
type FetchContentsArguments struct {
|
||||
@@ -30,40 +33,60 @@ func GetSearchTools() []openrouter.Tool {
|
||||
Type: openrouter.ToolTypeFunction,
|
||||
Function: &openrouter.FunctionDefinition{
|
||||
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{
|
||||
"type": "object",
|
||||
"required": []string{"query", "num_results"},
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"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{
|
||||
"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,
|
||||
"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,
|
||||
},
|
||||
Strict: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: openrouter.ToolTypeFunction,
|
||||
Function: &openrouter.FunctionDefinition{
|
||||
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{
|
||||
"type": "object",
|
||||
"required": []string{"urls"},
|
||||
"properties": map[string]any{
|
||||
"urls": map[string]any{
|
||||
"type": "array",
|
||||
"description": "List of URLs (1..N) to fetch.",
|
||||
"items": map[string]any{"type": "string"},
|
||||
"description": "List of URLs to fetch.",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 5,
|
||||
},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -75,14 +98,14 @@ func GetSearchTools() []openrouter.Tool {
|
||||
Type: openrouter.ToolTypeFunction,
|
||||
Function: &openrouter.FunctionDefinition{
|
||||
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{
|
||||
"type": "object",
|
||||
"required": []string{"owner", "repo"},
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{
|
||||
"type": "string",
|
||||
"description": "GitHub username or organization (e.g., 'torvalds').",
|
||||
"description": "Repository owner (e.g., 'torvalds').",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
@@ -100,7 +123,7 @@ func GetSearchTools() []openrouter.Tool {
|
||||
func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error {
|
||||
var arguments SearchWebArguments
|
||||
|
||||
err := json.Unmarshal([]byte(tool.Args), &arguments)
|
||||
err := ParseAndUpdateArgs(tool, &arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -132,7 +155,7 @@ func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error {
|
||||
func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error {
|
||||
var arguments FetchContentsArguments
|
||||
|
||||
err := json.Unmarshal([]byte(tool.Args), &arguments)
|
||||
err := ParseAndUpdateArgs(tool, &arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,7 +187,7 @@ func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error {
|
||||
func HandleGitHubRepositoryTool(ctx context.Context, tool *ToolCall) error {
|
||||
var arguments GitHubRepositoryArguments
|
||||
|
||||
err := json.Unmarshal([]byte(tool.Args), &arguments)
|
||||
err := ParseAndUpdateArgs(tool, &arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,3 +203,25 @@ func HandleGitHubRepositoryTool(ctx context.Context, tool *ToolCall) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseAndUpdateArgs(tool *ToolCall, arguments any) error {
|
||||
err := json.Unmarshal([]byte(tool.Args), arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := GetFreeBuffer()
|
||||
defer pool.Put(buf)
|
||||
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
err = enc.Encode(arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tool.Args = buf.String()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@@ -221,9 +221,23 @@ body:not(.loading) #loading {
|
||||
}
|
||||
|
||||
#title.refreshing #title-text {
|
||||
position: relative;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -320,6 +334,7 @@ body:not(.loading) #loading {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.message:not(.has-text) .text,
|
||||
.message:not(.has-tags) .tags {
|
||||
display: none;
|
||||
}
|
||||
@@ -378,10 +393,15 @@ body:not(.loading) #loading {
|
||||
border: 2px solid #ed8796;
|
||||
}
|
||||
|
||||
.tool .result pre,
|
||||
.reasoning-text pre {
|
||||
background: #1b1d2a;
|
||||
}
|
||||
|
||||
.tool .result pre.l-json .pre-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message .tool .result,
|
||||
.message .reasoning-text {
|
||||
background: #1e2030;
|
||||
@@ -390,6 +410,14 @@ body:not(.loading) #loading {
|
||||
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-tool: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;
|
||||
}
|
||||
|
||||
.message:empty.receiving .text::before,
|
||||
.message.waiting .text::before {
|
||||
content: ". . .";
|
||||
.message .loader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -705,15 +754,21 @@ body:not(.loading) #loading {
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
#chat:has(.has-files) #resize-bar {
|
||||
top: 46px;
|
||||
}
|
||||
|
||||
#attachments {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.files:not(.has-files) {
|
||||
@@ -1029,6 +1084,10 @@ label[for="reasoning-tokens"] {
|
||||
background-image: url(icons/screen.svg);
|
||||
}
|
||||
|
||||
#scrolling.not-following {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#json {
|
||||
background-image: url(icons/json-off.svg);
|
||||
}
|
||||
@@ -1189,6 +1248,32 @@ label[for="reasoning-tokens"] {
|
||||
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 {
|
||||
0% {
|
||||
transform: translate(0px);
|
||||
|
@@ -91,10 +91,26 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.markdown .table-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
cursor: grab;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.markdown .table-wrapper:not(.overflowing) {
|
||||
cursor: default;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.markdown .table-wrapper.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
@@ -33,7 +33,7 @@
|
||||
<div id="title-text"></div>
|
||||
</div>
|
||||
|
||||
<div id="messages"></div>
|
||||
<div id="messages" tabindex="0"></div>
|
||||
|
||||
<div id="chat">
|
||||
<button id="top" class="hidden" title="Scroll to top"></button>
|
||||
|
@@ -1,7 +1,6 @@
|
||||
(() => {
|
||||
const $version = document.getElementById("version"),
|
||||
$total = document.getElementById("total"),
|
||||
$notifications = document.getElementById("notifications"),
|
||||
$title = document.getElementById("title"),
|
||||
$titleRefresh = document.getElementById("title-refresh"),
|
||||
$titleText = document.getElementById("title-text"),
|
||||
@@ -34,7 +33,8 @@
|
||||
$password = document.getElementById("password"),
|
||||
$login = document.getElementById("login");
|
||||
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
const nearBottom = 22,
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
|
||||
let platform = "";
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
promptList = [];
|
||||
|
||||
let autoScrolling = false,
|
||||
followTail = true,
|
||||
awaitingScroll = false,
|
||||
jsonMode = false,
|
||||
searchTool = false,
|
||||
chatTitle = false;
|
||||
@@ -66,34 +68,6 @@
|
||||
$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() {
|
||||
const title = chatTitle || (messages.length ? "New Chat" : "");
|
||||
|
||||
@@ -106,26 +80,44 @@
|
||||
storeValue("title", chatTitle);
|
||||
}
|
||||
|
||||
function distanceFromBottom() {
|
||||
return $messages.scrollHeight - ($messages.scrollTop + $messages.clientHeight);
|
||||
}
|
||||
|
||||
function updateScrollButton() {
|
||||
const bottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight);
|
||||
const bottom = distanceFromBottom();
|
||||
|
||||
$top.classList.toggle("hidden", $messages.scrollTop < 80);
|
||||
$bottom.classList.toggle("hidden", bottom < 80);
|
||||
}
|
||||
|
||||
function setFollowTail(follow) {
|
||||
followTail = follow;
|
||||
|
||||
$scrolling.classList.toggle("not-following", !followTail);
|
||||
}
|
||||
|
||||
function scroll(force = false, instant = false) {
|
||||
if (!autoScrolling && !force) {
|
||||
if (awaitingScroll || !(followTail || force)) {
|
||||
updateScrollButton();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
awaitingScroll = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
awaitingScroll = false;
|
||||
|
||||
if (!followTail && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
$messages.scroll({
|
||||
top: $messages.scrollHeight,
|
||||
behavior: instant ? "instant" : "smooth",
|
||||
});
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function preloadIcons(icons) {
|
||||
@@ -215,6 +207,13 @@
|
||||
|
||||
this.#_message.appendChild(_body);
|
||||
|
||||
// loader
|
||||
const _loader = make("div", "loader");
|
||||
|
||||
_loader.innerHTML = "<span></span>".repeat(3);
|
||||
|
||||
_body.appendChild(_loader);
|
||||
|
||||
// message files
|
||||
this.#_files = make("div", "files");
|
||||
|
||||
@@ -233,12 +232,14 @@
|
||||
_reasoning.appendChild(_toggle);
|
||||
|
||||
_toggle.addEventListener("click", () => {
|
||||
_reasoning.classList.toggle("expanded");
|
||||
let delta = this.#updateReasoningHeight() + 16; // margin
|
||||
|
||||
if (_reasoning.classList.contains("expanded")) {
|
||||
this.#updateReasoningHeight();
|
||||
if (!_reasoning.classList.toggle("expanded")) {
|
||||
delta = -delta;
|
||||
}
|
||||
|
||||
setFollowTail(distanceFromBottom() + delta <= nearBottom);
|
||||
|
||||
updateScrollButton();
|
||||
});
|
||||
|
||||
@@ -267,6 +268,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
this.#_edit.addEventListener("input", () => {
|
||||
this.updateEditHeight();
|
||||
});
|
||||
|
||||
// message tool
|
||||
this.#_tool = make("div", "tool");
|
||||
|
||||
@@ -278,7 +283,13 @@
|
||||
this.#_tool.appendChild(_call);
|
||||
|
||||
_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();
|
||||
});
|
||||
@@ -320,6 +331,10 @@
|
||||
_optCollapse.addEventListener("click", () => {
|
||||
this.#_message.classList.toggle("collapsed");
|
||||
|
||||
updateScrollButton();
|
||||
|
||||
setFollowTail(distanceFromBottom() <= nearBottom);
|
||||
|
||||
this.#save();
|
||||
});
|
||||
|
||||
@@ -381,7 +396,7 @@
|
||||
|
||||
mark(false);
|
||||
|
||||
generate(false);
|
||||
generate(false, true);
|
||||
});
|
||||
|
||||
// edit option
|
||||
@@ -428,13 +443,20 @@
|
||||
}
|
||||
|
||||
#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() {
|
||||
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) {
|
||||
@@ -509,9 +531,9 @@
|
||||
_cost.textContent = cost ? `${formatMoney(cost)}` : "";
|
||||
|
||||
_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);
|
||||
|
||||
this.#_tool.setAttribute("data-tool", name);
|
||||
} else {
|
||||
@@ -520,8 +542,6 @@
|
||||
|
||||
this.#_message.classList.toggle("has-tool", !!this.#tool);
|
||||
|
||||
this.#updateToolHeight();
|
||||
|
||||
noScroll || scroll();
|
||||
|
||||
updateScrollButton();
|
||||
@@ -561,8 +581,6 @@
|
||||
|
||||
if (!only || only === "reasoning") {
|
||||
this.#patch("reasoning", this.#_reasoning, this.#reasoning, () => {
|
||||
this.#updateReasoningHeight();
|
||||
|
||||
noScroll || scroll();
|
||||
|
||||
updateScrollButton();
|
||||
@@ -797,6 +815,11 @@
|
||||
this.toggleEdit();
|
||||
}
|
||||
|
||||
updateEditHeight() {
|
||||
this.#_edit.style.height = "";
|
||||
this.#_edit.style.height = `${Math.max(100, this.#_edit.scrollHeight + 2)}px`;
|
||||
}
|
||||
|
||||
toggleEdit() {
|
||||
this.#editing = !this.#editing;
|
||||
|
||||
@@ -804,11 +827,10 @@
|
||||
activeMessage = this;
|
||||
|
||||
this.#_edit.value = this.#text;
|
||||
this.#_edit.style.height = "";
|
||||
|
||||
this.setState("editing");
|
||||
|
||||
this.#_edit.style.height = `${Math.max(100, this.#_edit.scrollHeight)}px`;
|
||||
this.updateEditHeight();
|
||||
|
||||
this.#_edit.focus();
|
||||
} else {
|
||||
@@ -821,6 +843,10 @@
|
||||
this.#render(false, true);
|
||||
this.#save();
|
||||
}
|
||||
|
||||
setFollowTail(distanceFromBottom() <= nearBottom);
|
||||
|
||||
updateScrollButton();
|
||||
}
|
||||
|
||||
delete() {
|
||||
@@ -834,6 +860,8 @@
|
||||
|
||||
messages.splice(index, 1);
|
||||
|
||||
setFollowTail(distanceFromBottom() <= nearBottom);
|
||||
|
||||
this.#save();
|
||||
|
||||
$messages.dispatchEvent(new Event("scroll"));
|
||||
@@ -932,7 +960,7 @@
|
||||
|
||||
let chatController;
|
||||
|
||||
function generate(cancel = false) {
|
||||
function generate(cancel = false, noPush = false) {
|
||||
if (chatController) {
|
||||
chatController.abort();
|
||||
|
||||
@@ -943,6 +971,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (autoScrolling) {
|
||||
setFollowTail(true);
|
||||
}
|
||||
|
||||
let temperature = parseFloat($temperature.value);
|
||||
|
||||
if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) {
|
||||
@@ -972,7 +1004,9 @@
|
||||
$reasoningTokens.classList.remove("invalid");
|
||||
}
|
||||
|
||||
pushMessage();
|
||||
if (!noPush) {
|
||||
pushMessage();
|
||||
}
|
||||
|
||||
chatController = new AbortController();
|
||||
|
||||
@@ -1298,7 +1332,9 @@
|
||||
}
|
||||
|
||||
if (message.tags) {
|
||||
message.tags.forEach(tag => obj.addTag(tag));
|
||||
message.tags.forEach(tag => {
|
||||
obj.addTag(tag);
|
||||
});
|
||||
}
|
||||
|
||||
if (message.tool) {
|
||||
@@ -1314,10 +1350,9 @@
|
||||
|
||||
updateTitle();
|
||||
|
||||
scroll();
|
||||
|
||||
// small fix, sometimes when hard reloading we don't scroll all the way
|
||||
setTimeout(scroll, 250);
|
||||
requestAnimationFrame(() => {
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
let attachments = [];
|
||||
@@ -1427,7 +1462,17 @@
|
||||
updateScrollButton();
|
||||
});
|
||||
|
||||
$messages.addEventListener("wheel", event => {
|
||||
if (event.deltaY < 0) {
|
||||
setFollowTail(false);
|
||||
} else {
|
||||
setFollowTail(distanceFromBottom() - event.deltaY <= nearBottom);
|
||||
}
|
||||
});
|
||||
|
||||
$bottom.addEventListener("click", () => {
|
||||
setFollowTail(true);
|
||||
|
||||
$messages.scroll({
|
||||
top: $messages.scrollHeight,
|
||||
behavior: "smooth",
|
||||
@@ -1435,6 +1480,8 @@
|
||||
});
|
||||
|
||||
$top.addEventListener("click", () => {
|
||||
setFollowTail($messages.scrollHeight <= $messages.clientHeight);
|
||||
|
||||
$messages.scroll({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
@@ -1442,7 +1489,7 @@
|
||||
});
|
||||
|
||||
$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) {
|
||||
$chat.style.height = "";
|
||||
@@ -1599,6 +1646,7 @@
|
||||
|
||||
$export.addEventListener("click", () => {
|
||||
const data = JSON.stringify({
|
||||
title: chatTitle,
|
||||
message: $message.value,
|
||||
attachments: attachments,
|
||||
role: $role.value,
|
||||
@@ -1639,6 +1687,7 @@
|
||||
|
||||
clearMessages();
|
||||
|
||||
storeValue("title", data.title);
|
||||
storeValue("message", data.message);
|
||||
storeValue("attachments", data.attachments);
|
||||
storeValue("role", data.role);
|
||||
@@ -1659,6 +1708,8 @@
|
||||
autoScrolling = !autoScrolling;
|
||||
|
||||
if (autoScrolling) {
|
||||
setFollowTail(true);
|
||||
|
||||
$scrolling.title = "Turn off auto-scrolling";
|
||||
$scrolling.classList.add("on");
|
||||
|
||||
@@ -1717,7 +1768,7 @@
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
@@ -1732,6 +1783,52 @@
|
||||
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($reasoningEffort);
|
||||
|
||||
|
@@ -114,6 +114,20 @@ function clamp(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) {
|
||||
let blob;
|
||||
|
||||
@@ -315,3 +329,35 @@ async function detectPlatform() {
|
||||
|
||||
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();
|
||||
};
|
||||
})();
|
||||
|
@@ -1,5 +1,12 @@
|
||||
(() => {
|
||||
const timeouts = new WeakMap();
|
||||
const timeouts = new WeakMap(),
|
||||
scrollState = {
|
||||
el: null,
|
||||
startX: 0,
|
||||
scrollLeft: 0,
|
||||
pointerId: null,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
marked.use({
|
||||
async: false,
|
||||
@@ -7,13 +14,13 @@
|
||||
gfm: true,
|
||||
pedantic: false,
|
||||
|
||||
walkTokens: (token) => {
|
||||
walkTokens: token => {
|
||||
const { type, text } = token;
|
||||
|
||||
if (type === "html") {
|
||||
token.text = token.text.replace(/&/g, "&")
|
||||
token.text = token.text.replace(/</g, "<")
|
||||
token.text = token.text.replace(/>/g, ">")
|
||||
token.text = token.text.replace(/&/g, "&");
|
||||
token.text = token.text.replace(/</g, "<");
|
||||
token.text = token.text.replace(/>/g, ">");
|
||||
|
||||
return;
|
||||
} else if (type !== "code") {
|
||||
@@ -41,16 +48,25 @@
|
||||
code(code) {
|
||||
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) {
|
||||
return `<a href="${link.href}" target="_blank">${escapeHtml(link.text || link.href)}</a>`;
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
postprocess: html => {
|
||||
html = html.replace(/<table>/g, `<div class="table-wrapper"><table>`);
|
||||
html = html.replace(/<\/ ?table>/g, `</table></div>`);
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
document.body.addEventListener("click", (event) => {
|
||||
addEventListener("click", event => {
|
||||
const button = event.target,
|
||||
header = button.closest(".pre-header"),
|
||||
pre = header?.closest("pre"),
|
||||
@@ -70,11 +86,76 @@
|
||||
pre,
|
||||
setTimeout(() => {
|
||||
button.classList.remove("copied");
|
||||
}, 1000),
|
||||
}, 1000)
|
||||
);
|
||||
});
|
||||
|
||||
window.render = (markdown) => {
|
||||
addEventListener("pointerover", event => {
|
||||
if (event.pointerType !== "mouse") {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = event.target.closest(".table-wrapper");
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.classList.toggle("overflowing", el.scrollWidth - el.clientWidth > 1);
|
||||
});
|
||||
|
||||
addEventListener("pointerdown", event => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = event.target.closest(".table-wrapper");
|
||||
|
||||
if (!el || !el.classList.contains("overflowing")) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollState.el = el;
|
||||
scrollState.pointerId = event.pointerId;
|
||||
scrollState.startX = event.clientX;
|
||||
scrollState.scrollLeft = el.scrollLeft;
|
||||
scrollState.moved = false;
|
||||
|
||||
el.classList.add("dragging");
|
||||
el.setPointerCapture?.(event.pointerId);
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
addEventListener("pointermove", event => {
|
||||
if (!scrollState.el || event.pointerId !== scrollState.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = event.clientX - scrollState.startX;
|
||||
|
||||
if (Math.abs(dx) > 3) {
|
||||
scrollState.moved = true;
|
||||
}
|
||||
|
||||
scrollState.el.scrollLeft = scrollState.scrollLeft - dx;
|
||||
});
|
||||
|
||||
function endScroll(event) {
|
||||
if (!scrollState.el || (event && event.pointerId !== scrollState.pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollState.el.classList.remove("dragging");
|
||||
scrollState.el.releasePointerCapture?.(scrollState.pointerId);
|
||||
scrollState.el = null;
|
||||
scrollState.pointerId = null;
|
||||
}
|
||||
|
||||
addEventListener("pointerup", endScroll);
|
||||
addEventListener("pointercancel", endScroll);
|
||||
|
||||
window.render = markdown => {
|
||||
return marked.parse(markdown);
|
||||
};
|
||||
})();
|
||||
|
27
stream.go
27
stream.go
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
type Chunk struct {
|
||||
Type string `json:"type"`
|
||||
Text any `json:"text"`
|
||||
Text any `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
@@ -27,6 +27,14 @@ var pool = sync.Pool{
|
||||
},
|
||||
}
|
||||
|
||||
func GetFreeBuffer() *bytes.Buffer {
|
||||
buf := pool.Get().(*bytes.Buffer)
|
||||
|
||||
buf.Reset()
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
func NewStream(w http.ResponseWriter, ctx context.Context) (*Stream, error) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
@@ -72,6 +80,18 @@ func IDChunk(id string) Chunk {
|
||||
}
|
||||
}
|
||||
|
||||
func EndChunk() Chunk {
|
||||
return Chunk{
|
||||
Type: "end",
|
||||
}
|
||||
}
|
||||
|
||||
func StartChunk() Chunk {
|
||||
return Chunk{
|
||||
Type: "start",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrorChunk(err error) Chunk {
|
||||
return Chunk{
|
||||
Type: "error",
|
||||
@@ -92,10 +112,7 @@ func WriteChunk(w http.ResponseWriter, ctx context.Context, chunk any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := pool.Get().(*bytes.Buffer)
|
||||
|
||||
buf.Reset()
|
||||
|
||||
buf := GetFreeBuffer()
|
||||
defer pool.Put(buf)
|
||||
|
||||
if err := json.NewEncoder(buf).Encode(chunk); err != nil {
|
||||
|
17
title.go
17
title.go
@@ -16,8 +16,7 @@ type TitleRequest struct {
|
||||
}
|
||||
|
||||
type TitleResponse struct {
|
||||
Title string `json:"title"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -83,9 +82,10 @@ func HandleTitle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var prompt strings.Builder
|
||||
buf := GetFreeBuffer()
|
||||
defer pool.Put(buf)
|
||||
|
||||
if err := InternalTitleTmpl.Execute(&prompt, raw); err != nil {
|
||||
if err := InternalTitleTmpl.Execute(buf, raw); err != nil {
|
||||
RespondJson(w, http.StatusInternalServerError, map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
@@ -96,7 +96,7 @@ func HandleTitle(w http.ResponseWriter, r *http.Request) {
|
||||
request := openrouter.ChatCompletionRequest{
|
||||
Model: env.Settings.TitleModel,
|
||||
Messages: []openrouter.ChatCompletionMessage{
|
||||
openrouter.SystemMessage(prompt.String()),
|
||||
openrouter.SystemMessage(buf.String()),
|
||||
openrouter.UserMessage(strings.Join(messages, "\n")),
|
||||
},
|
||||
Temperature: 0.25,
|
||||
@@ -146,7 +146,8 @@ func HandleTitle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result.Cost = cost
|
||||
|
||||
RespondJson(w, http.StatusOK, result)
|
||||
RespondJson(w, http.StatusOK, map[string]any{
|
||||
"title": result.Title,
|
||||
"cost": cost,
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user