diff --git a/.example.env b/.example.env index 1d98b8d..e8742b9 100644 --- a/.example.env +++ b/.example.env @@ -1,2 +1,5 @@ # Your openrouter.ai token OPENROUTER_TOKEN = "" + +# How many messages/tool calls before the model is cut-off +MAX_ITERATIONS = 3 diff --git a/.github/chat.png b/.github/chat.png index c8fc49e..ccaf2e7 100644 Binary files a/.github/chat.png and b/.github/chat.png differ diff --git a/chat.go b/chat.go index e09590f..905c459 100644 --- a/chat.go +++ b/chat.go @@ -1,18 +1,28 @@ package main import ( + "context" "encoding/json" "errors" "fmt" "io" "net/http" + "strings" "github.com/revrost/go-openrouter" ) +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Args string `json:"args"` + Result string `json:"result,omitempty"` +} + type Message struct { - Role string `json:"role"` - Text string `json:"text"` + Role string `json:"role"` + Text string `json:"text"` + Tool *ToolCall `json:"tool"` } type Reasoning struct { @@ -30,6 +40,27 @@ type Request struct { Messages []Message `json:"messages"` } +func (t *ToolCall) AsToolCall() openrouter.ToolCall { + return openrouter.ToolCall{ + ID: t.ID, + Type: openrouter.ToolTypeFunction, + Function: openrouter.FunctionCall{ + Name: t.Name, + Arguments: t.Args, + }, + } +} + +func (t *ToolCall) AsToolMessage() openrouter.ChatCompletionMessage { + return openrouter.ChatCompletionMessage{ + Role: openrouter.ChatMessageRoleTool, + ToolCallID: t.ID, + Content: openrouter.Content{ + Text: t.Result, + }, + } +} + func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { var request openrouter.ChatCompletionRequest @@ -67,10 +98,9 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { } } - if r.Search { - request.Plugins = append(request.Plugins, openrouter.ChatCompletionPlugin{ - ID: openrouter.PluginIDWeb, - }) + if model.Tools && r.Search { + request.Tools = GetSearchTool() + request.ToolChoice = "auto" } prompt, err := BuildPrompt(r.Prompt, model) @@ -83,16 +113,35 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { } for index, message := range r.Messages { - if message.Role != openrouter.ChatMessageRoleSystem && message.Role != openrouter.ChatMessageRoleAssistant && message.Role != openrouter.ChatMessageRoleUser { + switch message.Role { + case "system", "user": + request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{ + Role: message.Role, + Content: openrouter.Content{ + Text: message.Text, + }, + }) + case "assistant": + msg := openrouter.ChatCompletionMessage{ + Role: openrouter.ChatMessageRoleAssistant, + Content: openrouter.Content{ + Text: message.Text, + }, + } + + tool := message.Tool + if tool != nil { + msg.ToolCalls = []openrouter.ToolCall{tool.AsToolCall()} + + request.Messages = append(request.Messages, msg) + + msg = tool.AsToolMessage() + } + + request.Messages = append(request.Messages, msg) + default: return nil, fmt.Errorf("[%d] invalid role: %q", index+1, message.Role) } - - request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{ - Role: message.Role, - Content: openrouter.Content{ - Text: message.Text, - }, - }) } return &request, nil @@ -119,26 +168,10 @@ func HandleChat(w http.ResponseWriter, r *http.Request) { } request.Stream = true - request.Usage = &openrouter.IncludeUsage{ - Include: true, - } // DEBUG dump(request) - ctx := r.Context() - - stream, err := OpenRouterStartStream(ctx, *request) - if err != nil { - RespondJson(w, http.StatusBadRequest, map[string]any{ - "error": GetErrorMessage(err), - }) - - return - } - - defer stream.Close() - response, err := NewStream(w) if err != nil { RespondJson(w, http.StatusBadRequest, map[string]any{ @@ -148,18 +181,74 @@ func HandleChat(w http.ResponseWriter, r *http.Request) { return } - var id string + ctx := r.Context() + + for iteration := range MaxIterations { + if iteration == MaxIterations-1 { + request.Tools = nil + request.ToolChoice = "" + } + + tool, message, err := RunCompletion(ctx, response, request) + if err != nil { + response.Send(ErrorChunk(err)) + + return + } + + if tool == nil || tool.Name != "search_internet" { + return + } + + response.Send(ToolChunk(tool)) + + err = HandleSearchTool(ctx, tool) + if err != nil { + response.Send(ErrorChunk(err)) + + return + } + + response.Send(ToolChunk(tool)) + + request.Messages = append(request.Messages, + openrouter.ChatCompletionMessage{ + Role: openrouter.ChatMessageRoleAssistant, + Content: openrouter.Content{ + Text: message, + }, + ToolCalls: []openrouter.ToolCall{tool.AsToolCall()}, + }, + tool.AsToolMessage(), + ) + } +} + +func RunCompletion(ctx context.Context, response *Stream, request *openrouter.ChatCompletionRequest) (*ToolCall, string, error) { + stream, err := OpenRouterStartStream(ctx, *request) + if err != nil { + return nil, "", err + } + + defer stream.Close() + + var ( + id string + result strings.Builder + tool *ToolCall + ) for { chunk, err := stream.Recv() if err != nil { if errors.Is(err, io.EOF) { - return + break } - response.Send(ErrorChunk(err)) + log.Warning("stream error") + log.WarningE(err) - return + return nil, "", err } if id == "" { @@ -180,15 +269,35 @@ func HandleChat(w http.ResponseWriter, r *http.Request) { if choice.FinishReason == openrouter.FinishReasonContentFilter { response.Send(ErrorChunk(errors.New("stopped due to content_filter"))) - return + return nil, "", nil + } + + calls := choice.Delta.ToolCalls + + if len(calls) > 0 { + call := calls[0] + + if tool == nil { + tool = &ToolCall{} + } + + tool.ID += call.ID + tool.Name += call.Function.Name + tool.Args += call.Function.Arguments + } else if tool != nil { + break } content := choice.Delta.Content if content != "" { + result.WriteString(content) + response.Send(TextChunk(content)) } else if choice.Delta.Reasoning != nil { response.Send(ReasoningChunk(*choice.Delta.Reasoning)) } } + + return tool, result.String(), nil } diff --git a/env.go b/env.go index 90a8b4b..9631edc 100644 --- a/env.go +++ b/env.go @@ -2,14 +2,16 @@ package main import ( "errors" + "fmt" "os" + "strconv" "github.com/joho/godotenv" ) var ( Debug bool - NoOpen bool + MaxIterations int OpenRouterToken string ) @@ -17,7 +19,21 @@ func init() { log.MustPanic(godotenv.Load()) Debug = os.Getenv("DEBUG") == "true" - NoOpen = os.Getenv("NO_OPEN") == "true" + + if env := os.Getenv("MAX_ITERATIONS"); env != "" { + iterations, err := strconv.Atoi(env) + if err != nil { + log.Panic(fmt.Errorf("invalid max iterations: %v", err)) + } + + if iterations < 1 { + log.Panic(errors.New("max iterations has to be 1 or more")) + } + + MaxIterations = iterations + } else { + MaxIterations = 3 + } if OpenRouterToken = os.Getenv("OPENROUTER_TOKEN"); OpenRouterToken == "" { log.Panic(errors.New("missing openrouter token")) diff --git a/main.go b/main.go index 03096e5..bea1283 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,9 @@ package main import ( - "errors" "net/http" - "os/exec" "path/filepath" - "runtime" "strings" - "time" "github.com/coalaura/logger" adapter "github.com/coalaura/logger/http" @@ -46,17 +42,6 @@ func main() { r.Get("/-/stats/{id}", HandleStats) r.Post("/-/chat", HandleChat) - if !NoOpen { - time.AfterFunc(500*time.Millisecond, func() { - log.Info("Opening browser...") - - err := open("http://localhost:3443/") - if err != nil { - log.WarningE(err) - } - }) - } - log.Info("Listening at http://localhost:3443/") http.ListenAndServe(":3443", r) } @@ -73,16 +58,3 @@ func cache(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } - -func open(url string) error { - switch runtime.GOOS { - case "linux": - return exec.Command("xdg-open", url).Start() - case "windows": - return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - return exec.Command("open", url).Start() - } - - return errors.New("unsupported platform") -} diff --git a/models.go b/models.go index d17ee2e..84411d8 100644 --- a/models.go +++ b/models.go @@ -16,6 +16,7 @@ type Model struct { Reasoning bool `json:"-"` JSON bool `json:"-"` + Tools bool `json:"-"` } var ModelMap = make(map[string]*Model) @@ -41,18 +42,14 @@ func LoadModels() ([]*Model, error) { name = name[index+2:] } - tags, reasoning, json := GetModelTags(model) - m := &Model{ ID: model.ID, Name: name, Description: model.Description, - Tags: tags, - - Reasoning: reasoning, - JSON: json, } + GetModelTags(model, m) + models[index] = m ModelMap[model.ID] = m @@ -61,35 +58,29 @@ func LoadModels() ([]*Model, error) { return models, nil } -func GetModelTags(model openrouter.Model) ([]string, bool, bool) { - var ( - reasoning bool - json bool - tags []string - ) - +func GetModelTags(model openrouter.Model, m *Model) { for _, parameter := range model.SupportedParameters { switch parameter { case "reasoning": - reasoning = true + m.Reasoning = true - tags = append(tags, "reasoning") + m.Tags = append(m.Tags, "reasoning") case "response_format": - json = true + m.JSON = true - tags = append(tags, "json") + m.Tags = append(m.Tags, "json") case "tools": - tags = append(tags, "tools") + m.Tools = true + + m.Tags = append(m.Tags, "tools") } } for _, modality := range model.Architecture.InputModalities { if modality == "image" { - tags = append(tags, "vision") + m.Tags = append(m.Tags, "vision") } } - sort.Strings(tags) - - return tags, reasoning, json + sort.Strings(m.Tags) } diff --git a/openrouter.go b/openrouter.go index 5fb6630..d915d70 100644 --- a/openrouter.go +++ b/openrouter.go @@ -55,6 +55,12 @@ func OpenRouterStartStream(ctx context.Context, request openrouter.ChatCompletio return stream, nil } +func OpenRouterRun(ctx context.Context, request openrouter.ChatCompletionRequest) (openrouter.ChatCompletionResponse, error) { + client := OpenRouterClient() + + return client.CreateChatCompletion(ctx, request) +} + func OpenRouterGetGeneration(ctx context.Context, id string) (*Generation, error) { req, err := http.NewRequest("GET", fmt.Sprintf("https://openrouter.ai/api/v1/generation?id=%s", id), nil) if err != nil { diff --git a/prompts/search.txt b/prompts/search.txt new file mode 100644 index 0000000..e2557ff --- /dev/null +++ b/prompts/search.txt @@ -0,0 +1,28 @@ +You are an AI Web Search Assistant. Your task is to take a user's query, perform a web search if necessary, and then synthesize the findings into a clear, structured, and informative summary. This summary will be used by another AI to answer the user. + +Guidelines: +1. **Analyze the Query:** Understand the core intent. If the query implies a need for recent information (e.g., "latest," "recent," "this year") or a specific type of source (e.g., "news," "research paper," "official documentation"), prioritize that in your search and synthesis. +2. **Web Search:** Search the web to find relevant, up-to-date information. Focus on recent, up-to-date data. +3. **Synthesize Results:** + * Provide up to 3-5 of the most relevant search results. + * For each result, include: + * `Title:` The title of the webpage. + * `URL:` The direct URL. + * `Published Date:` (If available or inferable, format as YYYY-MM-DD. Omit if not found). + * `Summary:` A concise 2-3 sentence summary of the key information from the page relevant to the query. Focus on extracting factual details and key takeaways. + * Format each result clearly, separated by a blank line. +4. **Conciseness and Information Density:** Aim for maximum relevant information. Avoid conversational fluff, opinions, or introductory/concluding remarks. Just provide the structured search findings. +5. **No Direct Answer (Usually):** Your primary role is to provide summarized search results. Do not try to directly answer the user's original question in a conversational way unless the query is very simple and can be answered by a single, authoritative fact from the search. The other AI will handle the final conversational response. +6. **If No Good Results:** If the search yields no relevant results, state "No specific relevant information found for the query." + +Example Output Format: + +Title: [Page Title] +URL: [Page URL] +Published Date: [YYYY-MM-DD] (Omit if not found) +Summary: [Concise summary of key information relevant to the query.] + +Title: [Page Title] +URL: [Page URL] +Published Date: [YYYY-MM-DD] (Omit if not found) +Summary: [Concise summary of key information relevant to the query.] \ No newline at end of file diff --git a/search.go b/search.go new file mode 100644 index 0000000..f766734 --- /dev/null +++ b/search.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + + "github.com/revrost/go-openrouter" +) + +type SearchArguments struct { + Query string `json:"query"` +} + +var ( + //go:embed prompts/search.txt + PromptSearch string +) + +func GetSearchTool() []openrouter.Tool { + return []openrouter.Tool{ + { + Type: openrouter.ToolTypeFunction, + Function: &openrouter.FunctionDefinition{ + Name: "search_internet", + Description: "Search the internet for current information.", + Parameters: map[string]any{ + "type": "object", + "required": []string{"query"}, + "properties": map[string]any{ + "query": map[string]string{ + "type": "string", + "description": "A concise and specific query string.", + }, + }, + "additionalProperties": false, + }, + Strict: true, + }, + }, + } +} + +func HandleSearchTool(ctx context.Context, tool *ToolCall) error { + var arguments SearchArguments + + err := json.Unmarshal([]byte(tool.Args), &arguments) + if err != nil { + return err + } + + if arguments.Query == "" { + return errors.New("no search query") + } + + request := openrouter.ChatCompletionRequest{ + Model: "perplexity/sonar", + Messages: []openrouter.ChatCompletionMessage{ + openrouter.SystemMessage(PromptSearch), + openrouter.UserMessage(arguments.Query), + }, + Temperature: 0.25, + MaxTokens: 2048, + } + + response, err := OpenRouterRun(ctx, request) + if err != nil { + tool.Result = fmt.Sprintf("error: %v", err) + + return nil + } + + if len(response.Choices) == 0 { + tool.Result = "error: failed to perform search" + + return nil + } + + tool.Result = response.Choices[0].Message.Content.Text + + return nil +} diff --git a/static/css/chat.css b/static/css/chat.css index e15aab0..566ebd6 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -193,11 +193,11 @@ body.loading #version { display: none; } -#messages .message .tag-json { +.message .tag-json { background-image: url(icons/json-mode.svg); } -#messages .message .tag-search { +.message .tag-search { background-image: url(icons/search-tool.svg); } @@ -218,6 +218,7 @@ body.loading #version { } .message .reasoning, +.message .tool, .message .text { display: block; background: transparent; @@ -234,12 +235,13 @@ body.loading #version { display: none; } -#messages .message .reasoning, -#messages .message div.text { +.message .reasoning, +.message .tool, +.message div.text { background: #24273a; } -#messages .message textarea.text { +.message textarea.text { background: #181926; } @@ -260,6 +262,7 @@ body.loading #version { background: #1b1d2a; } +.message .tool .result, .message .reasoning-text { background: #1e2030; border-radius: 6px; @@ -282,11 +285,15 @@ body.loading #version { } .message.has-reasoning:not(.has-text) div.text, +.message.has-tool:not(.has-text) div.text, +.message:not(.has-tool) .tool, .message:not(.has-reasoning) .reasoning { display: none; } -#messages .message div.text { +.message .tool, +.message:not(.has-tool):not(.has-text) .reasoning, +.message:not(.has-tool) div.text { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; } @@ -295,6 +302,7 @@ body.loading #version { padding-top: 4px; } +.tool .call, .reasoning .toggle { position: relative; padding: 0 22px; @@ -303,6 +311,8 @@ body.loading #version { font-size: 14px; } +.tool .call .name::after, +.tool .call::before, .reasoning .toggle::after, .reasoning .toggle::before { content: ""; @@ -314,6 +324,7 @@ body.loading #version { height: 20px; } +.tool .call .name::after, .reasoning .toggle::after { background-image: url(icons/chevron.svg); left: unset; @@ -325,6 +336,65 @@ body.loading #version { transform: rotate(180deg); } +.message.has-tool .text { + padding-bottom: 4px; +} + +.message .tool { + --height: 0px; + overflow: hidden; + transition: 150ms; + height: calc(90px + var(--height)); +} + +.message .tool:not(.expanded) { + height: 62px; +} + +.tool .call { + display: flex; + flex-direction: column; + width: 100%; + cursor: pointer; + font-family: "Comic Code", ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Ubuntu Mono", "Roboto Mono", Menlo, Monaco, Consolas, monospace; + font-size: 13px; +} + +.tool .call .arguments { + font-style: italic; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tool .call .name::after, +.tool .call::before { + top: 50%; + transform: translateY(-50%); +} + +.tool .call .name::after { + right: -22px; +} + +.tool.expanded .call .name::after { + transform: translateY(-50%) rotate(180deg); +} + +.tool .call::before { + background-image: url(icons/tool.svg); +} + +.tool .call .name { + position: relative; + width: max-content; +} + +.message .tool .result { + margin-top: 16px; +} + .message .options { display: flex; gap: 4px; @@ -355,6 +425,13 @@ body.loading #version { height: 18px; } +.message.tooling .tool .call::before { + animation: rotating-y 1.2s linear infinite; + background-image: url(icons/spinner.svg); + width: 18px; + height: 18px; +} + .message .text::before { font-style: italic; } @@ -510,13 +587,15 @@ body.loading #version, .reasoning .toggle::before, .reasoning .toggle::after, #bottom, -#messages .message .role::before, -#messages .message .tag-json, -#messages .message .tag-search, -#messages .message .copy, -#messages .message .edit, -#messages .message .delete, +.message .role::before, +.message .tag-json, +.message .tag-search, +.message .copy, +.message .edit, +.message .delete, .pre-copy, +.tool .call .name::after, +.tool .call::before, .message .statistics .provider::before, .message .statistics .ttft::before, .message .statistics .tps::before, @@ -540,9 +619,9 @@ body.loading #version, .message .statistics .ttft::before, .message .statistics .tps::before, .message .statistics .tokens::before, -#messages .message .tag-json, -#messages .message .tag-search, -#messages .message .role::before { +.message .tag-json, +.message .tag-search, +.message .role::before { content: ""; width: 16px; height: 16px; @@ -553,7 +632,7 @@ input.invalid { } .pre-copy, -#messages .message .copy { +.message .copy { background-image: url(icons/copy.svg); } @@ -562,15 +641,15 @@ input.invalid { background-image: url(icons/check.svg); } -#messages .message .edit { +.message .edit { background-image: url(icons/edit.svg); } -#messages .message.editing .edit { +.message.editing .edit { background-image: url(icons/save.svg); } -#messages .message .delete { +.message .delete { background-image: url(icons/delete.svg); } @@ -692,4 +771,14 @@ label[for="reasoning-tokens"] { to { transform: rotate(360deg); } +} + +@keyframes rotating-y { + from { + transform: translateY(-50%) rotate(0deg); + } + + to { + transform: translateY(-50%) rotate(360deg); + } } \ No newline at end of file diff --git a/static/css/icons/tool.svg b/static/css/icons/tool.svg new file mode 100644 index 0000000..0b4e701 --- /dev/null +++ b/static/css/icons/tool.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/static/css/markdown.css b/static/css/markdown.css index 39aced8..0e048cf 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -176,6 +176,10 @@ padding-left: 28px; } +.markdown> :first-child { + margin-top: 0; +} + .markdown blockquote>*, .markdown td>*, .markdown th>*, diff --git a/static/js/chat.js b/static/js/chat.js index 5df79ff..4e96b27 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -22,11 +22,10 @@ let autoScrolling = false, jsonMode = false, - searchTool = false, - interacted = false; + searchTool = false; - function scroll(force = false) { - if (!autoScrolling && !force) { + function scroll() { + if (!autoScrolling) { return; } @@ -44,6 +43,7 @@ #reasoning; #text; + #tool; #tags = []; #statistics; #error = false; @@ -61,6 +61,7 @@ #_reasoning; #_text; #_edit; + #_tool; #_statistics; constructor(role, reasoning, text) { @@ -156,6 +157,35 @@ } }); + // message tool + this.#_tool = make("div", "tool"); + + this.#_message.appendChild(this.#_tool); + + // tool call + const _call = make("div", "call"); + + this.#_tool.appendChild(_call); + + _call.addEventListener("click", () => { + this.#_tool.classList.toggle("expanded"); + }); + + // tool call name + const _callName = make("div", "name"); + + _call.appendChild(_callName); + + // tool call arguments + const _callArguments = make("div", "arguments"); + + _call.appendChild(_callArguments); + + // tool call result + const _callResult = make("div", "result", "markdown"); + + this.#_tool.appendChild(_callResult); + // message options const _opts = make("div", "options"); @@ -220,7 +250,7 @@ img.classList.add("image"); img.addEventListener("load", () => { - scroll(!interacted); + scroll(); }); }); } @@ -232,6 +262,21 @@ ); } + #updateToolHeight() { + const result = this.#_tool.querySelector(".result"); + + this.#_tool.style.setProperty("--height", `${result.scrollHeight}px`); + } + + #morph(from, to) { + morphdom(from, to, { + childrenOnly: true, + onBeforeElUpdated: (fromEl, toEl) => { + return !fromEl.isEqualNode || !fromEl.isEqualNode(toEl); + }, + }); + } + #patch(name, element, md, after = false) { if (!element.firstChild) { element.innerHTML = render(md); @@ -258,12 +303,7 @@ this.#_diff.innerHTML = html; - morphdom(element, this.#_diff, { - childrenOnly: true, - onBeforeElUpdated: (fromEl, toEl) => { - return !fromEl.isEqualNode || !fromEl.isEqualNode(toEl); - }, - }); + this.#morph(element, this.#_diff); this.#_diff.innerHTML = ""; @@ -284,6 +324,34 @@ this.#_message.classList.toggle("has-tags", this.#tags.length > 0); } + if (!only || only === "tool") { + if (this.#tool) { + const { name, args, result } = this.#tool; + + const _name = this.#_tool.querySelector(".name"), + _arguments = this.#_tool.querySelector(".arguments"), + _result = this.#_tool.querySelector(".result"); + + _name.title = `Show ${name} call result`; + _name.textContent = name; + + _arguments.title = args; + _arguments.textContent = args; + + _result.innerHTML = render(result || "*processing*"); + + this.#_tool.setAttribute("data-tool", name); + } else { + this.#_tool.removeAttribute("data-tool"); + } + + this.#_message.classList.toggle("has-tool", !!this.#tool); + + this.#updateToolHeight(); + + noScroll || scroll(); + } + if (!only || only === "statistics") { let html = ""; @@ -353,6 +421,10 @@ text: this.#text, }; + if (this.#tool) { + data.tool = this.#tool; + } + if (this.#reasoning && full) { data.reasoning = this.#reasoning; } @@ -365,7 +437,7 @@ data.tags = this.#tags; } - if (this.#statistics) { + if (this.#statistics && full) { data.statistics = this.#statistics; } @@ -425,11 +497,23 @@ if (state) { this.#_message.classList.add(state); + } else { + if (this.#tool && !this.#tool.result) { + this.#tool.result = "failed to run tool"; + + this.#render("tool"); + } } this.#state = state; } + setTool(tool) { + this.#tool = tool; + + this.#render("tool"); + } + addReasoning(chunk) { this.#reasoning += chunk; @@ -652,19 +736,23 @@ obj.showError(message.error); } - if (message.statistics) { - obj.setStatistics(message.statistics); - } - if (message.tags) { message.tags.forEach((tag) => obj.addTag(tag)); } + + if (message.tool) { + obj.setTool(message.tool); + } + + if (message.statistics) { + obj.setStatistics(message.statistics); + } }); - scroll(true); + scroll(); // small fix, sometimes when hard reloading we don't scroll all the way - setTimeout(scroll, 250, true); + setTimeout(scroll, 250); } function pushMessage() { @@ -691,9 +779,7 @@ }); $bottom.addEventListener("click", () => { - interacted = true; - - scroll(true); + scroll(); }); $role.addEventListener("change", () => { @@ -702,11 +788,12 @@ $model.addEventListener("change", () => { const model = $model.value, - data = model ? models[model] : null; + data = model ? models[model] : null, + tags = data?.tags || []; storeValue("model", model); - if (data?.tags.includes("reasoning")) { + if (tags.includes("reasoning")) { $reasoningEffort.parentNode.classList.remove("none"); $reasoningTokens.parentNode.classList.toggle( "none", @@ -717,7 +804,13 @@ $reasoningTokens.parentNode.classList.add("none"); } - $json.classList.toggle("none", !data?.tags.includes("json")); + const hasJson = tags.includes("json"), + hasTools = tags.includes("tools"); + + $json.classList.toggle("none", !hasJson); + $search.classList.toggle("none", !hasTools); + + $search.parentNode.classList.toggle("none", !hasJson && !hasTools); }); $prompt.addEventListener("change", () => { @@ -862,19 +955,36 @@ .filter((data) => data?.text), }; - const message = new Message("assistant", "", ""); + let message, generationID; - message.setState("waiting"); + function finish() { + if (!message) { + return; + } - if (jsonMode) { - message.addTag("json"); + message.setState(false); + + setTimeout(message.loadGenerationData.bind(message), 750, generationID); + + message = null; + generationID = null; } - if (searchTool) { - message.addTag("search"); + function start() { + message = new Message("assistant", "", ""); + + message.setState("waiting"); + + if (jsonMode) { + message.addTag("json"); + } + + if (searchTool) { + message.addTag("search"); + } } - let generationID; + start(); stream( "/-/chat", @@ -890,21 +1000,30 @@ if (!chunk) { controller = null; - message.setState(false); + finish(); $chat.classList.remove("completing"); - setTimeout(() => { - message.loadGenerationData(generationID); - }, 750); - return; } + if (!message && chunk.type !== "end") { + start(); + } + switch (chunk.type) { case "id": generationID = chunk.text; + break; + case "tool": + message.setState("tooling"); + message.setTool(chunk.text); + + if (chunk.text.result) { + finish(); + } + break; case "reason": message.setState("reasoning"); diff --git a/stream.go b/stream.go index db129b5..a4a4d56 100644 --- a/stream.go +++ b/stream.go @@ -10,7 +10,7 @@ import ( type Chunk struct { Type string `json:"type"` - Text string `json:"text"` + Text any `json:"text"` } type Stream struct { @@ -64,6 +64,13 @@ func TextChunk(text string) Chunk { } } +func ToolChunk(tool *ToolCall) Chunk { + return Chunk{ + Type: "tool", + Text: tool, + } +} + func IDChunk(id string) Chunk { return Chunk{ Type: "id",