1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-08 17:06:42 +00:00

better searching

This commit is contained in:
Laura
2025-08-14 03:53:14 +02:00
parent 8a790df2af
commit c740cd293d
14 changed files with 582 additions and 143 deletions

View File

@@ -1,2 +1,5 @@
# Your openrouter.ai token
OPENROUTER_TOKEN = ""
# How many messages/tool calls before the model is cut-off
MAX_ITERATIONS = 3

BIN
.github/chat.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 132 KiB

179
chat.go
View File

@@ -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
}

20
env.go
View File

@@ -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"))

28
main.go
View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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 {

28
prompts/search.txt Normal file
View File

@@ -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.]

84
search.go Normal file
View File

@@ -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
}

View File

@@ -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);
}
}

11
static/css/icons/tool.svg Normal file
View File

@@ -0,0 +1,11 @@
<!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="256px" height="256px" viewBox="-2.4 -2.4 28.80 28.80" 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"/>
<g id="SVGRepo_iconCarrier"> <path d="M15.6316 7.63137C15.2356 7.23535 15.0376 7.03735 14.9634 6.80902C14.8981 6.60817 14.8981 6.39183 14.9634 6.19098C15.0376 5.96265 15.2356 5.76465 15.6316 5.36863L18.47 2.53026C17.7168 2.18962 16.8806 2 16.0002 2C12.6865 2 10.0002 4.68629 10.0002 8C10.0002 8.49104 10.0592 8.9683 10.1705 9.42509C10.2896 9.91424 10.3492 10.1588 10.3387 10.3133C10.3276 10.4751 10.3035 10.5612 10.2289 10.7051C10.1576 10.8426 10.0211 10.9791 9.74804 11.2522L3.50023 17.5C2.6718 18.3284 2.6718 19.6716 3.50023 20.5C4.32865 21.3284 5.6718 21.3284 6.50023 20.5L12.748 14.2522C13.0211 13.9791 13.1576 13.8426 13.2951 13.7714C13.4391 13.6968 13.5251 13.6727 13.6869 13.6616C13.8414 13.651 14.086 13.7106 14.5751 13.8297C15.0319 13.941 15.5092 14 16.0002 14C19.3139 14 22.0002 11.3137 22.0002 8C22.0002 7.11959 21.8106 6.28347 21.47 5.53026L18.6316 8.36863C18.2356 8.76465 18.0376 8.96265 17.8092 9.03684C17.6084 9.1021 17.3921 9.1021 17.1912 9.03684C16.9629 8.96265 16.7649 8.76465 16.3689 8.36863L15.6316 7.63137Z" stroke="#cad3f5" stroke-width="1.56" stroke-linecap="round" stroke-linejoin="round"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -176,6 +176,10 @@
padding-left: 28px;
}
.markdown> :first-child {
margin-top: 0;
}
.markdown blockquote>*,
.markdown td>*,
.markdown th>*,

View File

@@ -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");

View File

@@ -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",