mirror of
https://github.com/coalaura/whiskr.git
synced 2025-09-09 09:19:54 +00:00
Compare commits
37 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f18e9e577e | ||
![]() |
ce9813a331 | ||
![]() |
aeed519df0 | ||
![]() |
a819ec7b38 | ||
![]() |
db138324fa | ||
![]() |
2d65c2f484 | ||
![]() |
d903521154 | ||
![]() |
30755706fb | ||
![]() |
f240e30dd2 | ||
![]() |
6471aeb51b | ||
![]() |
eeb8c22415 | ||
![]() |
8943fd83bb | ||
![]() |
07ca59cfb1 | ||
![]() |
703d5373f0 | ||
![]() |
87d33a8d1d | ||
![]() |
c7a2848d05 | ||
![]() |
118e88ab67 | ||
![]() |
dc8ad8d408 | ||
![]() |
7abfd965db | ||
![]() |
fc0a34ee12 | ||
be17a801f8 | |||
c7c3bff2d8 | |||
3d629c93c5 | |||
58aa250abe | |||
![]() |
87ea9823d2 | ||
![]() |
40f98b0fd6 | ||
![]() |
413515340a | ||
![]() |
225cf59b4e | ||
![]() |
f14faa11f2 | ||
![]() |
98c6976dfa | ||
![]() |
b331920634 | ||
ca5693b08a | |||
![]() |
26ad8698b7 | ||
![]() |
5dbb0b0815 | ||
![]() |
5479286595 | ||
36cc50e90b | |||
7d48984703 |
73
README.md
73
README.md
@@ -6,32 +6,44 @@ 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
|
||||||
|
|
||||||
|
- restrict model list (optionally allow all for authenticated users)
|
||||||
|
- make authentication optional (unless no allowed models)
|
||||||
|
- improved custom prompts
|
||||||
- settings
|
- settings
|
||||||
- auto-retry on edit
|
- auto-retry on edit
|
||||||
- ctrl+enter vs enter for sending
|
- ctrl+enter vs enter for sending
|
||||||
@@ -87,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.
|
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;
|
||||||
@@ -116,13 +128,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 (``) 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 `` 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
|
||||||
|
|
||||||
|
68
chat.go
68
chat.go
@@ -14,11 +14,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ToolCall struct {
|
type ToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Args string `json:"args"`
|
Args string `json:"args"`
|
||||||
Result string `json:"result,omitempty"`
|
Result string `json:"result,omitempty"`
|
||||||
Done bool `json:"done,omitempty"`
|
Done bool `json:"done,omitempty"`
|
||||||
|
Invalid bool `json:"invalid,omitempty"`
|
||||||
|
Cost float64 `json:"cost,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TextFile struct {
|
type TextFile struct {
|
||||||
@@ -38,14 +40,24 @@ type Reasoning struct {
|
|||||||
Tokens int `json:"tokens"`
|
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 {
|
type Request struct {
|
||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
Iterations int64 `json:"iterations"`
|
Iterations int64 `json:"iterations"`
|
||||||
JSON bool `json:"json"`
|
Tools Tools `json:"tools"`
|
||||||
Search bool `json:"search"`
|
|
||||||
Reasoning Reasoning `json:"reasoning"`
|
Reasoning Reasoning `json:"reasoning"`
|
||||||
|
Metadata Metadata `json:"metadata"`
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,13 +130,13 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.JSON && r.JSON {
|
if model.JSON && r.Tools.JSON {
|
||||||
request.ResponseFormat = &openrouter.ChatCompletionResponseFormat{
|
request.ResponseFormat = &openrouter.ChatCompletionResponseFormat{
|
||||||
Type: openrouter.ChatCompletionResponseFormatTypeJSONObject,
|
Type: openrouter.ChatCompletionResponseFormatTypeJSONObject,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt, err := BuildPrompt(r.Prompt, model)
|
prompt, err := BuildPrompt(r.Prompt, r.Metadata, model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -133,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.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 {
|
||||||
@@ -245,7 +260,9 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
debug("preparing stream")
|
debug("preparing stream")
|
||||||
|
|
||||||
response, err := NewStream(w)
|
ctx := r.Context()
|
||||||
|
|
||||||
|
response, err := NewStream(w, ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RespondJson(w, http.StatusBadRequest, map[string]any{
|
RespondJson(w, http.StatusBadRequest, map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -256,12 +273,12 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
debug("handling request")
|
debug("handling request")
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
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 {
|
response.Send(StartChunk())
|
||||||
|
|
||||||
|
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
|
||||||
@@ -312,7 +329,8 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return
|
tool.Invalid = true
|
||||||
|
tool.Result = "error: invalid tool call"
|
||||||
}
|
}
|
||||||
|
|
||||||
tool.Done = true
|
tool.Done = true
|
||||||
@@ -325,6 +343,8 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
tool.AsAssistantToolCall(message),
|
tool.AsAssistantToolCall(message),
|
||||||
tool.AsToolMessage(),
|
tool.AsToolMessage(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
response.Send(EndChunk())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,11 +357,13 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch
|
|||||||
defer stream.Close()
|
defer stream.Close()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
id string
|
id string
|
||||||
result strings.Builder
|
tool *ToolCall
|
||||||
tool *ToolCall
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
buf := GetFreeBuffer()
|
||||||
|
defer pool.Put(buf)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
chunk, err := stream.Recv()
|
chunk, err := stream.Recv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -389,7 +411,7 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch
|
|||||||
content := choice.Delta.Content
|
content := choice.Delta.Content
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
result.WriteString(content)
|
buf.WriteString(content)
|
||||||
|
|
||||||
response.Send(TextChunk(content))
|
response.Send(TextChunk(content))
|
||||||
} else if choice.Delta.Reasoning != nil {
|
} else if choice.Delta.Reasoning != nil {
|
||||||
@@ -397,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 {
|
func SplitImagePairs(text string) []openrouter.ChatMessagePart {
|
||||||
@@ -409,6 +431,10 @@ func SplitImagePairs(text string) []openrouter.ChatMessagePart {
|
|||||||
)
|
)
|
||||||
|
|
||||||
push := func(str, end int) {
|
push := func(str, end int) {
|
||||||
|
if str > end {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rest := text[str:end]
|
rest := text[str:end]
|
||||||
|
|
||||||
if rest == "" {
|
if rest == "" {
|
||||||
|
1
clean.go
1
clean.go
@@ -5,6 +5,7 @@ import "strings"
|
|||||||
var cleaner = strings.NewReplacer(
|
var cleaner = strings.NewReplacer(
|
||||||
"‑", "-",
|
"‑", "-",
|
||||||
"—", "-",
|
"—", "-",
|
||||||
|
"–", "-",
|
||||||
|
|
||||||
"“", "\"",
|
"“", "\"",
|
||||||
"”", "\"",
|
"”", "\"",
|
||||||
|
2
debug.go
2
debug.go
@@ -19,7 +19,7 @@ func debug(format string, args ...any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf(format+"\n", args...)
|
log.Printf(format+"\n", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func debugIf(cond bool, format string, args ...any) {
|
func debugIf(cond bool, format string, args ...any) {
|
||||||
|
16
env.go
16
env.go
@@ -51,25 +51,25 @@ var env = Environment{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
file, err := os.OpenFile("config.yml", os.O_RDONLY, 0)
|
file, err := os.OpenFile("config.yml", os.O_RDONLY, 0)
|
||||||
log.MustPanic(err)
|
log.MustFail(err)
|
||||||
|
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
err = yaml.NewDecoder(file).Decode(&env)
|
err = yaml.NewDecoder(file).Decode(&env)
|
||||||
log.MustPanic(err)
|
log.MustFail(err)
|
||||||
|
|
||||||
log.MustPanic(env.Init())
|
log.MustFail(env.Init())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Environment) Init() error {
|
func (e *Environment) Init() error {
|
||||||
// print if debug is enabled
|
// print if debug is enabled
|
||||||
if e.Debug {
|
if e.Debug {
|
||||||
log.Warning("Debug mode enabled")
|
log.Warnln("Debug mode enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if server secret is set
|
// check if server secret is set
|
||||||
if e.Tokens.Secret == "" {
|
if e.Tokens.Secret == "" {
|
||||||
log.Warning("Missing tokens.secret, generating new...")
|
log.Warnln("Missing tokens.secret, generating new...")
|
||||||
|
|
||||||
key := make([]byte, 32)
|
key := make([]byte, 32)
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func (e *Environment) Init() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Stored new tokens.secret")
|
log.Println("Stored new tokens.secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if openrouter token is set
|
// check if openrouter token is set
|
||||||
@@ -95,12 +95,12 @@ func (e *Environment) Init() error {
|
|||||||
|
|
||||||
// check if exa token is set
|
// check if exa token is set
|
||||||
if e.Tokens.Exa == "" {
|
if e.Tokens.Exa == "" {
|
||||||
log.Warning("Missing token.exa, web search unavailable")
|
log.Warnln("Missing token.exa, web search unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if github token is set
|
// check if github token is set
|
||||||
if e.Tokens.GitHub == "" {
|
if e.Tokens.GitHub == "" {
|
||||||
log.Warning("Missing token.github, limited api requests")
|
log.Warnln("Missing token.github, limited api requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
// default title model
|
// default title model
|
||||||
|
125
exa.go
125
exa.go
@@ -6,55 +6,39 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"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"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Highlights []string `json:"highlights,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
Text string `json:"text"`
|
type ExaCost struct {
|
||||||
Summary string `json:"summary"`
|
Total float64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExaResults struct {
|
type ExaResults struct {
|
||||||
RequestID string `json:"requestId"`
|
RequestID string `json:"requestId"`
|
||||||
Results []ExaResult `json:"results"`
|
SearchType string `json:"resolvedSearchType"`
|
||||||
}
|
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ExaResults) String() string {
|
func (e *ExaResults) String() string {
|
||||||
list := make([]string, len(e.Results))
|
buf := GetFreeBuffer()
|
||||||
|
defer pool.Put(buf)
|
||||||
|
|
||||||
for i, result := range e.Results {
|
json.NewEncoder(buf).Encode(map[string]any{
|
||||||
list[i] = result.String()
|
"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) {
|
func NewExaRequest(ctx context.Context, path string, data any) (*http.Request, error) {
|
||||||
@@ -95,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)
|
||||||
@@ -116,10 +147,16 @@ 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)
|
||||||
@@ -129,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)
|
||||||
|
}
|
||||||
|
53
github.go
53
github.go
@@ -162,14 +162,14 @@ func RepoOverview(ctx context.Context, arguments GitHubRepositoryArguments) (str
|
|||||||
|
|
||||||
readme, err := GitHubRepositoryReadmeJson(ctx, arguments.Owner, arguments.Repo, repository.DefaultBranch)
|
readme, err := GitHubRepositoryReadmeJson(ctx, arguments.Owner, arguments.Repo, repository.DefaultBranch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warningf("failed to get repository readme: %v\n", err)
|
log.Warnf("failed to get repository readme: %v\n", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
markdown, err := readme.AsText()
|
markdown, err := readme.AsText()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warningf("failed to decode repository readme: %v\n", err)
|
log.Warnf("failed to decode repository readme: %v\n", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@ func RepoOverview(ctx context.Context, arguments GitHubRepositoryArguments) (str
|
|||||||
|
|
||||||
contents, err := GitHubRepositoryContentsJson(ctx, arguments.Owner, arguments.Repo, repository.DefaultBranch)
|
contents, err := GitHubRepositoryContentsJson(ctx, arguments.Owner, arguments.Repo, repository.DefaultBranch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warningf("failed to get repository contents: %v\n", err)
|
log.Warnf("failed to get repository contents: %v\n", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,35 +220,36 @@ func RepoOverview(ctx context.Context, arguments GitHubRepositoryArguments) (str
|
|||||||
// wait and combine results
|
// wait and combine results
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
var builder strings.Builder
|
buf := GetFreeBuffer()
|
||||||
|
defer pool.Put(buf)
|
||||||
|
|
||||||
fmt.Fprintf(&builder, "### %s (%s)\n", repository.Name, repository.Visibility)
|
fmt.Fprintf(buf, "### %s (%s)\n", repository.Name, repository.Visibility)
|
||||||
fmt.Fprintf(&builder, "- URL: %s\n", repository.HtmlURL)
|
fmt.Fprintf(buf, "- URL: %s\n", repository.HtmlURL)
|
||||||
fmt.Fprintf(&builder, "- Description: %s\n", strings.ReplaceAll(repository.Description, "\n", " "))
|
fmt.Fprintf(buf, "- Description: %s\n", strings.ReplaceAll(repository.Description, "\n", " "))
|
||||||
fmt.Fprintf(&builder, "- Default branch: %s\n", repository.DefaultBranch)
|
fmt.Fprintf(buf, "- Default branch: %s\n", repository.DefaultBranch)
|
||||||
fmt.Fprintf(&builder, "- Stars: %d | Forks: %d\n", repository.Stargazers, repository.Forks)
|
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 {
|
if len(directories) == 0 && len(files) == 0 {
|
||||||
builder.WriteString("*No entries or insufficient permissions.*\n")
|
buf.WriteString("*No entries or insufficient permissions.*\n")
|
||||||
} else {
|
} else {
|
||||||
for _, directory := range directories {
|
for _, directory := range directories {
|
||||||
fmt.Fprintf(&builder, "- [D] %s\n", directory)
|
fmt.Fprintf(buf, "- [D] %s\n", directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
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 == "" {
|
if readmeMarkdown == "" {
|
||||||
builder.WriteString("*No README found or could not load.*\n")
|
buf.WriteString("*No README found or could not load.*\n")
|
||||||
} else {
|
} else {
|
||||||
builder.WriteString(readmeMarkdown)
|
buf.WriteString(readmeMarkdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
17
go.mod
17
go.mod
@@ -1,22 +1,21 @@
|
|||||||
module chat
|
module chat
|
||||||
|
|
||||||
go 1.24.5
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coalaura/logger v1.5.1
|
github.com/coalaura/plain v0.2.0
|
||||||
github.com/go-chi/chi/v5 v5.2.2
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/goccy/go-yaml v1.18.0
|
github.com/goccy/go-yaml v1.18.0
|
||||||
github.com/revrost/go-openrouter v0.2.2
|
github.com/revrost/go-openrouter v0.2.3
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.42.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/containerd/console v1.0.5 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/gookit/color v1.5.4 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/term v0.35.0 // indirect
|
||||||
golang.org/x/term v0.34.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
25
go.sum
25
go.sum
@@ -1,17 +1,17 @@
|
|||||||
github.com/coalaura/logger v1.5.1 h1:2no4UP1HYOKQBasAol7RP81V0emJ2sfJIIoKOtrATqM=
|
github.com/coalaura/plain v0.2.0 h1:naGiTT1nmZO78IGHOajm0wc/X4sqaG6g3CSR3Ha9f6w=
|
||||||
github.com/coalaura/logger v1.5.1/go.mod h1:npioUhSPFmjxOmLzYbl9X0G6sdZgvuMikTlmc6VitWo=
|
github.com/coalaura/plain v0.2.0/go.mod h1:HR/sQt288EMTF3aSEGKHwPmGYFU4FOrfarMUf6ifnLo=
|
||||||
|
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
|
||||||
|
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
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/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
|
||||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
@@ -24,23 +24,28 @@ 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/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 h1:7bOdLPKmw0iJB1AdpN+YaWUd2XC9cwfJKDY10iaSAzI=
|
||||||
github.com/revrost/go-openrouter v0.2.2/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0=
|
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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
|
||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
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.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.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 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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.
|
17
main.go
17
main.go
@@ -6,30 +6,27 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/coalaura/logger"
|
"github.com/coalaura/plain"
|
||||||
adapter "github.com/coalaura/logger/http"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
var log = logger.New().DetectTerminal().WithOptions(logger.Options{
|
var log = plain.New(plain.WithDate(plain.RFC3339Local))
|
||||||
NoLevel: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
icons, err := LoadIcons()
|
icons, err := LoadIcons()
|
||||||
log.MustPanic(err)
|
log.MustFail(err)
|
||||||
|
|
||||||
models, err := LoadModels()
|
models, err := LoadModels()
|
||||||
log.MustPanic(err)
|
log.MustFail(err)
|
||||||
|
|
||||||
log.Info("Preparing router...")
|
log.Println("Preparing router...")
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(adapter.Middleware(log))
|
r.Use(log.Middleware())
|
||||||
|
|
||||||
fs := http.FileServer(http.Dir("./static"))
|
fs := http.FileServer(http.Dir("./static"))
|
||||||
r.Handle("/*", cache(http.StripPrefix("/", fs)))
|
r.Handle("/*", cache(http.StripPrefix("/", fs)))
|
||||||
@@ -56,7 +53,7 @@ func main() {
|
|||||||
gr.Post("/-/chat", HandleChat)
|
gr.Post("/-/chat", HandleChat)
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Info("Listening at http://localhost:3443/")
|
log.Println("Listening at http://localhost:3443/")
|
||||||
http.ListenAndServe(":3443", r)
|
http.ListenAndServe(":3443", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -23,7 +23,7 @@ type Model struct {
|
|||||||
var ModelMap = make(map[string]*Model)
|
var ModelMap = make(map[string]*Model)
|
||||||
|
|
||||||
func LoadModels() ([]*Model, error) {
|
func LoadModels() ([]*Model, error) {
|
||||||
log.Info("Loading models...")
|
log.Println("Loading models...")
|
||||||
|
|
||||||
client := OpenRouterClient()
|
client := OpenRouterClient()
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func LoadModels() ([]*Model, error) {
|
|||||||
ModelMap[model.ID] = m
|
ModelMap[model.ID] = m
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Loaded %d models\n", len(models))
|
log.Printf("Loaded %d models\n", len(models))
|
||||||
|
|
||||||
return models, nil
|
return models, nil
|
||||||
}
|
}
|
||||||
|
44
prompts.go
44
prompts.go
@@ -15,9 +15,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PromptData struct {
|
type PromptData struct {
|
||||||
Name string
|
Name string
|
||||||
Slug string
|
Slug string
|
||||||
Date string
|
Date string
|
||||||
|
Platform string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Prompt struct {
|
type Prompt struct {
|
||||||
@@ -46,7 +47,7 @@ func init() {
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
Prompts, err = LoadPrompts()
|
Prompts, err = LoadPrompts()
|
||||||
log.MustPanic(err)
|
log.MustFail(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTemplate(name, text string) *template.Template {
|
func NewTemplate(name, text string) *template.Template {
|
||||||
@@ -58,7 +59,7 @@ func NewTemplate(name, text string) *template.Template {
|
|||||||
func LoadPrompts() ([]Prompt, error) {
|
func LoadPrompts() ([]Prompt, error) {
|
||||||
var prompts []Prompt
|
var prompts []Prompt
|
||||||
|
|
||||||
log.Info("Loading prompts...")
|
log.Println("Loading prompts...")
|
||||||
|
|
||||||
err := filepath.Walk("prompts", func(path string, info fs.FileInfo, err error) error {
|
err := filepath.Walk("prompts", func(path string, info fs.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
@@ -79,7 +80,7 @@ func LoadPrompts() ([]Prompt, error) {
|
|||||||
|
|
||||||
index := bytes.Index(body, []byte("---"))
|
index := bytes.Index(body, []byte("---"))
|
||||||
if index == -1 {
|
if index == -1 {
|
||||||
log.Warningf("Invalid prompt file: %q\n", path)
|
log.Warnf("Invalid prompt file: %q\n", path)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -87,7 +88,7 @@ func LoadPrompts() ([]Prompt, error) {
|
|||||||
prompt := Prompt{
|
prompt := Prompt{
|
||||||
Key: strings.Replace(filepath.Base(path), ".txt", "", 1),
|
Key: strings.Replace(filepath.Base(path), ".txt", "", 1),
|
||||||
Name: strings.TrimSpace(string(body[:index])),
|
Name: strings.TrimSpace(string(body[:index])),
|
||||||
Text: strings.TrimSpace(string(body[:index+3])),
|
Text: strings.TrimSpace(string(body[index+3:])),
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts = append(prompts, prompt)
|
prompts = append(prompts, prompt)
|
||||||
@@ -105,12 +106,12 @@ func LoadPrompts() ([]Prompt, error) {
|
|||||||
return prompts[i].Name < prompts[j].Name
|
return prompts[i].Name < prompts[j].Name
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Infof("Loaded %d prompts\n", len(prompts))
|
log.Printf("Loaded %d prompts\n", len(prompts))
|
||||||
|
|
||||||
return prompts, nil
|
return prompts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildPrompt(name string, model *Model) (string, error) {
|
func BuildPrompt(name string, metadata Metadata, model *Model) (string, error) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -120,12 +121,27 @@ func BuildPrompt(name string, model *Model) (string, error) {
|
|||||||
return "", fmt.Errorf("unknown prompt: %q", name)
|
return "", fmt.Errorf("unknown prompt: %q", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
tz := time.UTC
|
||||||
|
|
||||||
err := tmpl.Execute(&buf, PromptData{
|
if metadata.Timezone != "" {
|
||||||
Name: model.Name,
|
parsed, err := time.LoadLocation(metadata.Timezone)
|
||||||
Slug: model.ID,
|
if err == nil {
|
||||||
Date: time.Now().Format(time.RFC1123),
|
tz = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Platform == "" {
|
||||||
|
metadata.Platform = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := GetFreeBuffer()
|
||||||
|
defer pool.Put(buf)
|
||||||
|
|
||||||
|
err := tmpl.Execute(buf, PromptData{
|
||||||
|
Name: model.Name,
|
||||||
|
Slug: model.ID,
|
||||||
|
Date: time.Now().In(tz).Format(time.RFC1123),
|
||||||
|
Platform: metadata.Platform,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
Data Analyst
|
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
|
## Role & Expertise
|
||||||
- **Primary Role**: Data analyst with expertise in statistical analysis, pattern recognition, and business intelligence
|
- **Primary Role**: Data analyst with expertise in statistical analysis, pattern recognition, and business intelligence
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
Prompt Engineer
|
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
|
## Role & Expertise
|
||||||
- **Primary Role**: Senior prompt engineer with deep knowledge of LLM behavior, cognitive architectures, and optimization techniques
|
- **Primary Role**: Senior prompt engineer with deep knowledge of LLM behavior, cognitive architectures, and optimization techniques
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
Assistant
|
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
|
## Core Identity & Approach
|
||||||
- **Role**: General-purpose AI assistant with broad knowledge and problem-solving capabilities
|
- **Role**: General-purpose AI assistant with broad knowledge and problem-solving capabilities
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
Physics Explainer
|
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
|
## Role & Expertise
|
||||||
- **Primary Role**: Physics educator with deep conceptual understanding and exceptional communication skills
|
- **Primary Role**: Physics educator with deep conceptual understanding and exceptional communication skills
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
Research Assistant
|
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
|
## Role & Expertise
|
||||||
- **Primary Role**: Research methodologist skilled in systematic information gathering, source evaluation, and evidence synthesis
|
- **Primary Role**: Research methodologist skilled in systematic information gathering, source evaluation, and evidence synthesis
|
||||||
@@ -19,7 +19,8 @@ Follow this systematic approach for all research tasks:
|
|||||||
## Web Search Protocol
|
## Web Search Protocol
|
||||||
**When search tools are available:**
|
**When search tools are available:**
|
||||||
- Begin with: "Research Plan: I will search for [X], then [Y] to cross-reference findings"
|
- Begin with: "Research Plan: I will search for [X], then [Y] to cross-reference findings"
|
||||||
- Use multiple search angles to ensure comprehensive coverage
|
- Use multiple search turns, focusing on one specific query per turn to ensure precision and avoid convoluted results
|
||||||
|
- Use multiple search angles across turns to ensure comprehensive coverage
|
||||||
- Prioritize authoritative sources (academic, official, established organizations)
|
- Prioritize authoritative sources (academic, official, established organizations)
|
||||||
- Cross-verify claims across independent sources
|
- Cross-verify claims across independent sources
|
||||||
- Note when sources conflict and explain discrepancies
|
- Note when sources conflict and explain discrepancies
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
Code Reviewer
|
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
|
## Role & Expertise
|
||||||
- **Primary Role**: Senior code reviewer with deep expertise in security vulnerabilities, performance optimization, and maintainable code practices
|
- **Primary Role**: Senior code reviewer with deep expertise in security vulnerabilities, performance optimization, and maintainable code practices
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
Shell Scripter
|
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
|
## Role & Expertise
|
||||||
- **Primary Role**: Senior DevOps engineer and automation specialist with deep expertise in Bash, PowerShell, and cross-platform scripting
|
- **Primary Role**: Senior DevOps engineer and automation specialist with deep expertise in Bash, PowerShell, and cross-platform scripting
|
||||||
|
79
search.go
79
search.go
@@ -11,8 +11,11 @@ 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
|
||||||
}
|
}
|
||||||
@@ -116,6 +139,8 @@ func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tool.Cost = results.Cost.Total
|
||||||
|
|
||||||
if len(results.Results) == 0 {
|
if len(results.Results) == 0 {
|
||||||
tool.Result = "error: no search results"
|
tool.Result = "error: no search results"
|
||||||
|
|
||||||
@@ -130,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
|
||||||
}
|
}
|
||||||
@@ -146,6 +171,8 @@ func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tool.Cost = results.Cost.Total
|
||||||
|
|
||||||
if len(results.Results) == 0 {
|
if len(results.Results) == 0 {
|
||||||
tool.Result = "error: no search results"
|
tool.Result = "error: no search results"
|
||||||
|
|
||||||
@@ -160,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
|
||||||
}
|
}
|
||||||
@@ -176,3 +203,25 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
@@ -131,6 +131,7 @@ body:not(.loading) #loading {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 15px;
|
top: 15px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
|
z-index: 45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification {
|
.notification {
|
||||||
@@ -220,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;
|
||||||
@@ -319,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;
|
||||||
}
|
}
|
||||||
@@ -347,36 +363,28 @@ body:not(.loading) #loading {
|
|||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .reasoning,
|
|
||||||
.message .tool,
|
|
||||||
.message .text {
|
|
||||||
display: block;
|
|
||||||
background: transparent;
|
|
||||||
padding: 10px 12px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .reasoning {
|
|
||||||
padding-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message:not(.editing) textarea.text,
|
.message:not(.editing) textarea.text,
|
||||||
.message.editing div.text {
|
.message.editing div.text {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .reasoning,
|
|
||||||
.message .tool,
|
|
||||||
.message div.text {
|
|
||||||
background: #24273a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message textarea.text {
|
.message textarea.text {
|
||||||
|
display: block;
|
||||||
background: #181926;
|
background: #181926;
|
||||||
min-width: 480px;
|
min-width: 480px;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
width: calc(700px - 24px);
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message.assistant textarea.text {
|
||||||
|
width: calc(800px - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .tool.invalid,
|
||||||
|
.message .tool .result.error,
|
||||||
.message .text .error {
|
.message .text .error {
|
||||||
color: #ed8796;
|
color: #ed8796;
|
||||||
}
|
}
|
||||||
@@ -385,30 +393,29 @@ 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;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .reasoning-wrapper {
|
.reasoning-text strong {
|
||||||
--height: auto;
|
display: block;
|
||||||
height: calc(var(--height) + 20px);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: 150ms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message:not(.expanded) .reasoning-wrapper {
|
.reasoning-text strong:not(:first-child) {
|
||||||
height: 0;
|
margin-top: 14px;
|
||||||
}
|
|
||||||
|
|
||||||
.message.expanded .reasoning-text {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.has-reasoning:not(.has-text):not(.errored) div.text,
|
.message.has-reasoning:not(.has-text):not(.errored) div.text,
|
||||||
@@ -420,13 +427,34 @@ body:not(.loading) #loading {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message .body {
|
.message .body {
|
||||||
|
position: relative;
|
||||||
border-bottom-left-radius: 6px;
|
border-bottom-left-radius: 6px;
|
||||||
border-bottom-right-radius: 6px;
|
border-bottom-right-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
background: #24273a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.has-reasoning .text {
|
.message.collapsed .body {
|
||||||
padding-top: 4px;
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.collapsed .body>* {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,
|
.tool .call,
|
||||||
@@ -446,7 +474,7 @@ body:not(.loading) #loading {
|
|||||||
background-image: url(icons/reasoning.svg);
|
background-image: url(icons/reasoning.svg);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -2px;
|
top: -2px;
|
||||||
left: -2px;
|
left: 0px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
@@ -459,23 +487,25 @@ body:not(.loading) #loading {
|
|||||||
transition: 150ms;
|
transition: 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.expanded .reasoning .toggle::after {
|
.message .reasoning,
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.has-tool .text {
|
|
||||||
padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .tool {
|
.message .tool {
|
||||||
--height: 0px;
|
--height: 0px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: 150ms;
|
transition: 150ms;
|
||||||
height: calc(90px + var(--height));
|
height: calc(40px + 16px + var(--height));
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .reasoning {
|
||||||
|
height: calc(18px + 16px + var(--height));
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .reasoning:not(.expanded) {
|
||||||
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .tool:not(.expanded) {
|
.message .tool:not(.expanded) {
|
||||||
height: 62px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool .call {
|
.tool .call {
|
||||||
@@ -505,8 +535,12 @@ body:not(.loading) #loading {
|
|||||||
right: -22px;
|
right: -22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reasoning.expanded .toggle::after {
|
||||||
|
transform: scaleY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
.tool.expanded .call .name::after {
|
.tool.expanded .call .name::after {
|
||||||
transform: translateY(-50%) rotate(180deg);
|
transform: translateY(-50%) scaleY(-100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool .call::before {
|
.tool .call::before {
|
||||||
@@ -518,8 +552,19 @@ body:not(.loading) #loading {
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .tool .result {
|
.tool .cost {
|
||||||
margin-top: 16px;
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #a5adcb;
|
||||||
|
transition: 150ms opacity;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool:hover .cost {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .options {
|
.message .options {
|
||||||
@@ -538,9 +583,31 @@ body:not(.loading) #loading {
|
|||||||
pointer-events: all;
|
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 .copy,
|
||||||
.message.errored .options .edit,
|
.message.errored .options .edit,
|
||||||
.message.errored .options .retry,
|
|
||||||
.message.waiting .options,
|
.message.waiting .options,
|
||||||
.message.reasoning .options,
|
.message.reasoning .options,
|
||||||
.message.tooling .options,
|
.message.tooling .options,
|
||||||
@@ -567,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 {
|
||||||
@@ -666,15 +754,21 @@ 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;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files {
|
.files {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files:not(.has-files) {
|
.files:not(.has-files) {
|
||||||
@@ -684,6 +778,7 @@ body:not(.loading) #loading {
|
|||||||
.message .files {
|
.message .files {
|
||||||
background: #181926;
|
background: #181926;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files .file {
|
.files .file {
|
||||||
@@ -780,6 +875,7 @@ select {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message .options .collapse::after,
|
||||||
#chat .option+.option::before {
|
#chat .option+.option::before {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
@@ -803,6 +899,8 @@ body.loading #version,
|
|||||||
.message .role::before,
|
.message .role::before,
|
||||||
.message .tag-json,
|
.message .tag-json,
|
||||||
.message .tag-search,
|
.message .tag-search,
|
||||||
|
.message .collapse,
|
||||||
|
.message .collapse::before,
|
||||||
.message .copy,
|
.message .copy,
|
||||||
.message .edit,
|
.message .edit,
|
||||||
.message .retry,
|
.message .retry,
|
||||||
@@ -850,6 +948,10 @@ input.invalid {
|
|||||||
border: 1px solid #ed8796;
|
border: 1px solid #ed8796;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message .collapse::before {
|
||||||
|
background-image: url(icons/collapse.svg);
|
||||||
|
}
|
||||||
|
|
||||||
.pre-copy,
|
.pre-copy,
|
||||||
.message .copy {
|
.message .copy {
|
||||||
background-image: url(icons/copy.svg);
|
background-image: url(icons/copy.svg);
|
||||||
@@ -982,6 +1084,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);
|
||||||
}
|
}
|
||||||
@@ -1142,6 +1248,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);
|
||||||
|
7
static/css/icons/collapse.svg
Normal file
7
static/css/icons/collapse.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 567 B |
@@ -72,6 +72,10 @@
|
|||||||
text-decoration-color: rgba(183, 189, 248, 0.6);
|
text-decoration-color: rgba(183, 189, 248, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown img {
|
.markdown img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -87,10 +91,26 @@
|
|||||||
border-radius: 4px;
|
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 {
|
.markdown table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 16px 0;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
@@ -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,12 +33,25 @@
|
|||||||
$password = document.getElementById("password"),
|
$password = document.getElementById("password"),
|
||||||
$login = document.getElementById("login");
|
$login = document.getElementById("login");
|
||||||
|
|
||||||
|
const nearBottom = 22,
|
||||||
|
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||||
|
|
||||||
|
let platform = "";
|
||||||
|
|
||||||
|
detectPlatform().then(result => {
|
||||||
|
platform = result;
|
||||||
|
|
||||||
|
console.info(`Detected platform: ${platform}`);
|
||||||
|
});
|
||||||
|
|
||||||
const messages = [],
|
const messages = [],
|
||||||
models = {},
|
models = {},
|
||||||
modelList = [],
|
modelList = [],
|
||||||
promptList = [];
|
promptList = [];
|
||||||
|
|
||||||
let autoScrolling = false,
|
let autoScrolling = false,
|
||||||
|
followTail = true,
|
||||||
|
awaitingScroll = false,
|
||||||
jsonMode = false,
|
jsonMode = false,
|
||||||
searchTool = false,
|
searchTool = false,
|
||||||
chatTitle = false;
|
chatTitle = false;
|
||||||
@@ -56,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" : "");
|
||||||
|
|
||||||
@@ -96,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) {
|
||||||
@@ -143,7 +145,6 @@
|
|||||||
#error = false;
|
#error = false;
|
||||||
|
|
||||||
#editing = false;
|
#editing = false;
|
||||||
#expanded = false;
|
|
||||||
#state = false;
|
#state = false;
|
||||||
|
|
||||||
#_diff;
|
#_diff;
|
||||||
@@ -159,7 +160,7 @@
|
|||||||
#_tool;
|
#_tool;
|
||||||
#_statistics;
|
#_statistics;
|
||||||
|
|
||||||
constructor(role, reasoning, text, files = []) {
|
constructor(role, reasoning, text, files = [], collapsed = false) {
|
||||||
this.#id = uid();
|
this.#id = uid();
|
||||||
this.#role = role;
|
this.#role = role;
|
||||||
this.#reasoning = reasoning || "";
|
this.#reasoning = reasoning || "";
|
||||||
@@ -167,7 +168,7 @@
|
|||||||
|
|
||||||
this.#_diff = document.createElement("div");
|
this.#_diff = document.createElement("div");
|
||||||
|
|
||||||
this.#build();
|
this.#build(collapsed);
|
||||||
this.#render();
|
this.#render();
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -181,9 +182,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#build() {
|
#build(collapsed) {
|
||||||
// main message div
|
// main message div
|
||||||
this.#_message = make("div", "message", this.#role);
|
this.#_message = make("div", "message", this.#role, collapsed ? "collapsed" : "");
|
||||||
|
|
||||||
// message role (wrapper)
|
// message role (wrapper)
|
||||||
const _wrapper = make("div", "role", this.#role);
|
const _wrapper = make("div", "role", this.#role);
|
||||||
@@ -206,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");
|
||||||
|
|
||||||
@@ -224,26 +232,21 @@
|
|||||||
_reasoning.appendChild(_toggle);
|
_reasoning.appendChild(_toggle);
|
||||||
|
|
||||||
_toggle.addEventListener("click", () => {
|
_toggle.addEventListener("click", () => {
|
||||||
this.#expanded = !this.#expanded;
|
let delta = this.#updateReasoningHeight() + 16; // margin
|
||||||
|
|
||||||
this.#_message.classList.toggle("expanded", this.#expanded);
|
if (!_reasoning.classList.toggle("expanded")) {
|
||||||
|
delta = -delta;
|
||||||
if (this.#expanded) {
|
|
||||||
this.#updateReasoningHeight();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFollowTail(distanceFromBottom() + delta <= nearBottom);
|
||||||
|
|
||||||
updateScrollButton();
|
updateScrollButton();
|
||||||
});
|
});
|
||||||
|
|
||||||
// message reasoning (height wrapper)
|
|
||||||
const _height = make("div", "reasoning-wrapper");
|
|
||||||
|
|
||||||
_reasoning.appendChild(_height);
|
|
||||||
|
|
||||||
// message reasoning (content)
|
// message reasoning (content)
|
||||||
this.#_reasoning = make("div", "reasoning-text", "markdown");
|
this.#_reasoning = make("div", "reasoning-text", "markdown");
|
||||||
|
|
||||||
_height.appendChild(this.#_reasoning);
|
_reasoning.appendChild(this.#_reasoning);
|
||||||
|
|
||||||
// message content
|
// message content
|
||||||
this.#_text = make("div", "text", "markdown");
|
this.#_text = make("div", "text", "markdown");
|
||||||
@@ -265,6 +268,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.#_edit.addEventListener("input", () => {
|
||||||
|
this.updateEditHeight();
|
||||||
|
});
|
||||||
|
|
||||||
// message tool
|
// message tool
|
||||||
this.#_tool = make("div", "tool");
|
this.#_tool = make("div", "tool");
|
||||||
|
|
||||||
@@ -276,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();
|
||||||
});
|
});
|
||||||
@@ -291,6 +304,13 @@
|
|||||||
|
|
||||||
_call.appendChild(_callArguments);
|
_call.appendChild(_callArguments);
|
||||||
|
|
||||||
|
// tool call cost
|
||||||
|
const _callCost = make("div", "cost");
|
||||||
|
|
||||||
|
_callCost.title = "Cost of this tool call";
|
||||||
|
|
||||||
|
this.#_tool.appendChild(_callCost);
|
||||||
|
|
||||||
// tool call result
|
// tool call result
|
||||||
const _callResult = make("div", "result", "markdown");
|
const _callResult = make("div", "result", "markdown");
|
||||||
|
|
||||||
@@ -301,6 +321,23 @@
|
|||||||
|
|
||||||
this.#_message.appendChild(_opts);
|
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");
|
||||||
|
|
||||||
|
updateScrollButton();
|
||||||
|
|
||||||
|
setFollowTail(distanceFromBottom() <= nearBottom);
|
||||||
|
|
||||||
|
this.#save();
|
||||||
|
});
|
||||||
|
|
||||||
// copy option
|
// copy option
|
||||||
const _optCopy = make("button", "copy");
|
const _optCopy = make("button", "copy");
|
||||||
|
|
||||||
@@ -359,7 +396,7 @@
|
|||||||
|
|
||||||
mark(false);
|
mark(false);
|
||||||
|
|
||||||
generate(false);
|
generate(false, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// edit option
|
// edit option
|
||||||
@@ -406,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) {
|
||||||
@@ -471,10 +515,11 @@
|
|||||||
|
|
||||||
if (!only || only === "tool") {
|
if (!only || only === "tool") {
|
||||||
if (this.#tool) {
|
if (this.#tool) {
|
||||||
const { name, args, result } = this.#tool;
|
const { name, args, result, cost, invalid } = this.#tool;
|
||||||
|
|
||||||
const _name = this.#_tool.querySelector(".name"),
|
const _name = this.#_tool.querySelector(".name"),
|
||||||
_arguments = this.#_tool.querySelector(".arguments"),
|
_arguments = this.#_tool.querySelector(".arguments"),
|
||||||
|
_cost = this.#_tool.querySelector(".cost"),
|
||||||
_result = this.#_tool.querySelector(".result");
|
_result = this.#_tool.querySelector(".result");
|
||||||
|
|
||||||
_name.title = `Show ${name} call result`;
|
_name.title = `Show ${name} call result`;
|
||||||
@@ -483,7 +528,12 @@
|
|||||||
_arguments.title = args;
|
_arguments.title = args;
|
||||||
_arguments.textContent = args;
|
_arguments.textContent = args;
|
||||||
|
|
||||||
_result.innerHTML = render(result || "*processing*");
|
_cost.textContent = cost ? `${formatMoney(cost)}` : "";
|
||||||
|
|
||||||
|
_result.classList.toggle("error", result?.startsWith("error: "));
|
||||||
|
_result.innerHTML = render(result ? wrapJSON(result) : "*processing*");
|
||||||
|
|
||||||
|
this.#_tool.classList.toggle("invalid", !!invalid);
|
||||||
|
|
||||||
this.#_tool.setAttribute("data-tool", name);
|
this.#_tool.setAttribute("data-tool", name);
|
||||||
} else {
|
} else {
|
||||||
@@ -492,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();
|
||||||
@@ -533,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();
|
||||||
@@ -615,6 +661,10 @@
|
|||||||
data.statistics = this.#statistics;
|
data.statistics = this.#statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.#_message.classList.contains("collapsed") && full) {
|
||||||
|
data.collapsed = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.files?.length && !data.reasoning && !data.text && !data.tool) {
|
if (!data.files?.length && !data.reasoning && !data.text && !data.tool) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -651,7 +701,7 @@
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
if (!retrying && err.message.includes("not found")) {
|
if (!retrying && err.message.includes("not found")) {
|
||||||
setTimeout(this.loadGenerationData.bind(this), 750, generationID, true);
|
setTimeout(this.loadGenerationData.bind(this), 1500, generationID, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,6 +815,11 @@
|
|||||||
this.toggleEdit();
|
this.toggleEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateEditHeight() {
|
||||||
|
this.#_edit.style.height = "";
|
||||||
|
this.#_edit.style.height = `${Math.max(100, this.#_edit.scrollHeight + 2)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
toggleEdit() {
|
toggleEdit() {
|
||||||
this.#editing = !this.#editing;
|
this.#editing = !this.#editing;
|
||||||
|
|
||||||
@@ -773,11 +828,10 @@
|
|||||||
|
|
||||||
this.#_edit.value = this.#text;
|
this.#_edit.value = this.#text;
|
||||||
|
|
||||||
this.#_edit.style.height = `${this.#_text.offsetHeight}px`;
|
|
||||||
this.#_edit.style.width = `${this.#_text.offsetWidth}px`;
|
|
||||||
|
|
||||||
this.setState("editing");
|
this.setState("editing");
|
||||||
|
|
||||||
|
this.updateEditHeight();
|
||||||
|
|
||||||
this.#_edit.focus();
|
this.#_edit.focus();
|
||||||
} else {
|
} else {
|
||||||
activeMessage = null;
|
activeMessage = null;
|
||||||
@@ -789,6 +843,10 @@
|
|||||||
this.#render(false, true);
|
this.#render(false, true);
|
||||||
this.#save();
|
this.#save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFollowTail(distanceFromBottom() <= nearBottom);
|
||||||
|
|
||||||
|
updateScrollButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
delete() {
|
delete() {
|
||||||
@@ -802,6 +860,8 @@
|
|||||||
|
|
||||||
messages.splice(index, 1);
|
messages.splice(index, 1);
|
||||||
|
|
||||||
|
setFollowTail(distanceFromBottom() <= nearBottom);
|
||||||
|
|
||||||
this.#save();
|
this.#save();
|
||||||
|
|
||||||
$messages.dispatchEvent(new Event("scroll"));
|
$messages.dispatchEvent(new Event("scroll"));
|
||||||
@@ -900,15 +960,21 @@
|
|||||||
|
|
||||||
let chatController;
|
let chatController;
|
||||||
|
|
||||||
function generate(cancel = false) {
|
function generate(cancel = false, noPush = false) {
|
||||||
if (chatController) {
|
if (chatController) {
|
||||||
chatController.abort();
|
chatController.abort();
|
||||||
|
|
||||||
if (cancel) {
|
if (cancel) {
|
||||||
|
$chat.classList.remove("completing");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -938,7 +1004,9 @@
|
|||||||
$reasoningTokens.classList.remove("invalid");
|
$reasoningTokens.classList.remove("invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
pushMessage();
|
if (!noPush) {
|
||||||
|
pushMessage();
|
||||||
|
}
|
||||||
|
|
||||||
chatController = new AbortController();
|
chatController = new AbortController();
|
||||||
|
|
||||||
@@ -949,12 +1017,18 @@
|
|||||||
model: $model.value,
|
model: $model.value,
|
||||||
temperature: temperature,
|
temperature: temperature,
|
||||||
iterations: iterations,
|
iterations: iterations,
|
||||||
|
tools: {
|
||||||
|
json: jsonMode,
|
||||||
|
search: searchTool,
|
||||||
|
},
|
||||||
reasoning: {
|
reasoning: {
|
||||||
effort: effort,
|
effort: effort,
|
||||||
tokens: tokens || 0,
|
tokens: tokens || 0,
|
||||||
},
|
},
|
||||||
json: jsonMode,
|
metadata: {
|
||||||
search: searchTool,
|
timezone: timezone,
|
||||||
|
platform: platform,
|
||||||
|
},
|
||||||
messages: messages.map(message => message.getData()).filter(Boolean),
|
messages: messages.map(message => message.getData()).filter(Boolean),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -968,7 +1042,7 @@
|
|||||||
message.setState(false);
|
message.setState(false);
|
||||||
|
|
||||||
if (!aborted) {
|
if (!aborted) {
|
||||||
setTimeout(message.loadGenerationData.bind(message), 750, generationID);
|
setTimeout(message.loadGenerationData.bind(message), 1000, generationID);
|
||||||
}
|
}
|
||||||
|
|
||||||
message = null;
|
message = null;
|
||||||
@@ -1003,6 +1077,8 @@
|
|||||||
},
|
},
|
||||||
chunk => {
|
chunk => {
|
||||||
if (chunk === "aborted") {
|
if (chunk === "aborted") {
|
||||||
|
chatController = null;
|
||||||
|
|
||||||
finish(true);
|
finish(true);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -1038,6 +1114,8 @@
|
|||||||
message.setTool(chunk.text);
|
message.setTool(chunk.text);
|
||||||
|
|
||||||
if (chunk.text.done) {
|
if (chunk.text.done) {
|
||||||
|
totalCost += chunk.text.cost || 0;
|
||||||
|
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1247,14 +1325,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadValue("messages", []).forEach(message => {
|
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) {
|
if (message.error) {
|
||||||
obj.showError(message.error);
|
obj.showError(message.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -1270,10 +1350,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 = [];
|
||||||
@@ -1383,7 +1462,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",
|
||||||
@@ -1391,6 +1480,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$top.addEventListener("click", () => {
|
$top.addEventListener("click", () => {
|
||||||
|
setFollowTail($messages.scrollHeight <= $messages.clientHeight);
|
||||||
|
|
||||||
$messages.scroll({
|
$messages.scroll({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
@@ -1398,7 +1489,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 = "";
|
||||||
@@ -1508,7 +1599,7 @@
|
|||||||
$upload.addEventListener("click", async () => {
|
$upload.addEventListener("click", async () => {
|
||||||
const files = await selectFile(
|
const files = await selectFile(
|
||||||
// the ultimate list
|
// the ultimate list
|
||||||
".adoc,.bash,.bashrc,.bat,.c,.cc,.cfg,.cjs,.cmd,.conf,.cpp,.cs,.css,.csv,.cxx,.dockerfile,.dockerignore,.editorconfig,.env,.fish,.fs,.fsx,.gitattributes,.gitignore,.go,.gradle,.groovy,.h,.hh,.hpp,.htm,.html,.ini,.ipynb,.java,.jl,.js,.json,.jsonc,.jsx,.kt,.kts,.less,.log,.lua,.m,.makefile,.markdown,.md,.mjs,.mk,.mm,.php,.phtml,.pl,.pm,.profile,.properties,.ps1,.psql,.py,.pyw,.r,.rb,.rs,.rst,.sass,.scala,.scss,.sh,.sql,.svelte,.swift,.t,.toml,.ts,.tsv,.tsx,.txt,.vb,.vue,.xhtml,.xml,.xsd,.xsl,.xslt,.yaml,.yml,.zig,.zsh",
|
"text/*",
|
||||||
true,
|
true,
|
||||||
file => {
|
file => {
|
||||||
if (!file.name) {
|
if (!file.name) {
|
||||||
@@ -1517,10 +1608,10 @@
|
|||||||
throw new Error("File name too long (max 512 characters)");
|
throw new Error("File name too long (max 512 characters)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof file.content !== "string") {
|
if (!file.content) {
|
||||||
throw new Error("File is not a text file");
|
|
||||||
} else if (!file.content) {
|
|
||||||
throw new Error("File is empty");
|
throw new Error("File is empty");
|
||||||
|
} else if (file.content.includes("\0")) {
|
||||||
|
throw new Error("File is not a text file");
|
||||||
} else if (file.content.length > 4 * 1024 * 1024) {
|
} else if (file.content.length > 4 * 1024 * 1024) {
|
||||||
throw new Error("File is too big (max 4MB)");
|
throw new Error("File is too big (max 4MB)");
|
||||||
}
|
}
|
||||||
@@ -1555,6 +1646,7 @@
|
|||||||
|
|
||||||
$export.addEventListener("click", () => {
|
$export.addEventListener("click", () => {
|
||||||
const data = JSON.stringify({
|
const data = JSON.stringify({
|
||||||
|
title: chatTitle,
|
||||||
message: $message.value,
|
message: $message.value,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
role: $role.value,
|
role: $role.value,
|
||||||
@@ -1595,6 +1687,7 @@
|
|||||||
|
|
||||||
clearMessages();
|
clearMessages();
|
||||||
|
|
||||||
|
storeValue("title", data.title);
|
||||||
storeValue("message", data.message);
|
storeValue("message", data.message);
|
||||||
storeValue("attachments", data.attachments);
|
storeValue("attachments", data.attachments);
|
||||||
storeValue("role", data.role);
|
storeValue("role", data.role);
|
||||||
@@ -1615,6 +1708,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");
|
||||||
|
|
||||||
@@ -1673,7 +1768,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`;
|
||||||
|
|
||||||
@@ -1688,6 +1783,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);
|
||||||
|
|
||||||
|
139
static/js/lib.js
139
static/js/lib.js
@@ -45,6 +45,8 @@ function uid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function make(tag, ...classes) {
|
function make(tag, ...classes) {
|
||||||
|
classes = classes.filter(Boolean);
|
||||||
|
|
||||||
const el = document.createElement(tag);
|
const el = document.createElement(tag);
|
||||||
|
|
||||||
if (classes.length) {
|
if (classes.length) {
|
||||||
@@ -112,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;
|
||||||
|
|
||||||
@@ -222,3 +238,126 @@ function selectFile(accept, multiple, handler, onError = false) {
|
|||||||
input.click();
|
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}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
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({
|
marked.use({
|
||||||
async: false,
|
async: false,
|
||||||
@@ -7,13 +14,13 @@
|
|||||||
gfm: true,
|
gfm: true,
|
||||||
pedantic: false,
|
pedantic: false,
|
||||||
|
|
||||||
walkTokens: (token) => {
|
walkTokens: token => {
|
||||||
const { type, text } = token;
|
const { type, text } = token;
|
||||||
|
|
||||||
if (type === "html") {
|
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;
|
return;
|
||||||
} else if (type !== "code") {
|
} else if (type !== "code") {
|
||||||
@@ -41,16 +48,25 @@
|
|||||||
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) {
|
||||||
return `<a href="${link.href}" target="_blank">${escapeHtml(link.text || link.href)}</a>`;
|
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,
|
const button = event.target,
|
||||||
header = button.closest(".pre-header"),
|
header = button.closest(".pre-header"),
|
||||||
pre = header?.closest("pre"),
|
pre = header?.closest("pre"),
|
||||||
@@ -70,11 +86,76 @@
|
|||||||
pre,
|
pre,
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
button.classList.remove("copied");
|
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);
|
return marked.parse(markdown);
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
91
stream.go
91
stream.go
@@ -1,55 +1,55 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/revrost/go-openrouter"
|
"github.com/revrost/go-openrouter"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Chunk struct {
|
type Chunk struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text any `json:"text"`
|
Text any `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stream struct {
|
type Stream struct {
|
||||||
wr http.ResponseWriter
|
wr http.ResponseWriter
|
||||||
fl http.Flusher
|
ctx context.Context
|
||||||
en *json.Encoder
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStream(w http.ResponseWriter) (*Stream, error) {
|
var pool = sync.Pool{
|
||||||
flusher, ok := w.(http.Flusher)
|
New: func() interface{} {
|
||||||
if !ok {
|
return &bytes.Buffer{}
|
||||||
return nil, errors.New("failed to create flusher")
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
return &Stream{
|
return &Stream{
|
||||||
wr: w,
|
wr: w,
|
||||||
fl: flusher,
|
ctx: ctx,
|
||||||
en: json.NewEncoder(w),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) Send(ch Chunk) error {
|
func (s *Stream) Send(ch Chunk) error {
|
||||||
debugIf(ch.Type == "error", "error: %v", ch.Text)
|
debugIf(ch.Type == "error", "error: %v", ch.Text)
|
||||||
|
|
||||||
if err := s.en.Encode(ch); err != nil {
|
return WriteChunk(s.wr, s.ctx, ch)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.wr.Write([]byte("\n\n")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.fl.Flush()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReasoningChunk(text string) Chunk {
|
func ReasoningChunk(text string) Chunk {
|
||||||
@@ -80,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 {
|
func ErrorChunk(err error) Chunk {
|
||||||
return Chunk{
|
return Chunk{
|
||||||
Type: "error",
|
Type: "error",
|
||||||
@@ -94,3 +106,36 @@ func GetErrorMessage(err error) string {
|
|||||||
|
|
||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WriteChunk(w http.ResponseWriter, ctx context.Context, chunk any) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := GetFreeBuffer()
|
||||||
|
defer pool.Put(buf)
|
||||||
|
|
||||||
|
if err := json.NewEncoder(buf).Encode(chunk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Write([]byte("\n\n"))
|
||||||
|
|
||||||
|
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("failed to create flusher")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
17
title.go
17
title.go
@@ -16,8 +16,7 @@ type TitleRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TitleResponse struct {
|
type TitleResponse struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cost float64 `json:"cost,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -83,9 +82,10 @@ func HandleTitle(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
RespondJson(w, http.StatusInternalServerError, map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
@@ -96,7 +96,7 @@ func HandleTitle(w http.ResponseWriter, r *http.Request) {
|
|||||||
request := openrouter.ChatCompletionRequest{
|
request := openrouter.ChatCompletionRequest{
|
||||||
Model: env.Settings.TitleModel,
|
Model: env.Settings.TitleModel,
|
||||||
Messages: []openrouter.ChatCompletionMessage{
|
Messages: []openrouter.ChatCompletionMessage{
|
||||||
openrouter.SystemMessage(prompt.String()),
|
openrouter.SystemMessage(buf.String()),
|
||||||
openrouter.UserMessage(strings.Join(messages, "\n")),
|
openrouter.UserMessage(strings.Join(messages, "\n")),
|
||||||
},
|
},
|
||||||
Temperature: 0.25,
|
Temperature: 0.25,
|
||||||
@@ -146,7 +146,8 @@ func HandleTitle(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Cost = cost
|
RespondJson(w, http.StatusOK, map[string]any{
|
||||||
|
"title": result.Title,
|
||||||
RespondJson(w, http.StatusOK, result)
|
"cost": cost,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user