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

better search tools

This commit is contained in:
Laura
2025-08-14 17:08:45 +02:00
parent 5d44980510
commit 0b51ee9dad
17 changed files with 335 additions and 102 deletions

View File

@@ -1,5 +1,11 @@
# Your openrouter.ai token
# Your openrouter.ai token (required)
OPENROUTER_TOKEN = ""
# How many messages/tool calls before the model is cut-off
# Your exa-search token (optional)
EXA_TOKEN = ""
# How many messages/tool calls before the model is cut-off (optional, default: 3)
MAX_ITERATIONS = 3
# Replace unicode quotes, dashes, etc. in the assistants output (optional, default: false)
CLEAN_CONTENT = true

47
chat.go
View File

@@ -17,6 +17,7 @@ type ToolCall struct {
Name string `json:"name"`
Args string `json:"args"`
Result string `json:"result,omitempty"`
Done bool `json:"done,omitempty"`
}
type Message struct {
@@ -98,9 +99,11 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
}
}
if model.Tools && r.Search {
request.Tools = GetSearchTool()
if model.Tools && r.Search && ExaToken != "" {
request.Tools = GetSearchTools()
request.ToolChoice = "auto"
request.Messages = append(request.Messages, openrouter.SystemMessage("You have access to web search tools. Use `search_web` with `query` (string) and `num_results` (1-10) to find current information and get result summaries. Use `fetch_contents` with `urls` (array) to read full page content. Always specify all parameters for each tool call. Call only one tool per response."))
}
prompt, err := BuildPrompt(r.Prompt, model)
@@ -148,6 +151,8 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
}
func HandleChat(w http.ResponseWriter, r *http.Request) {
debug("new chat")
var raw Request
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
@@ -169,9 +174,6 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
request.Stream = true
// DEBUG
dump(request)
response, err := NewStream(w)
if err != nil {
RespondJson(w, http.StatusBadRequest, map[string]any{
@@ -181,12 +183,20 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
return
}
debug("handling request")
ctx := r.Context()
for iteration := range MaxIterations {
debug("iteration %d of %d", iteration+1, MaxIterations)
if iteration == MaxIterations-1 {
debug("no more tool calls")
request.Tools = nil
request.ToolChoice = ""
request.Messages = append(request.Messages, openrouter.SystemMessage("You have reached the maximum number of tool calls for this conversation. Provide your final response based on the information you have gathered."))
}
tool, message, err := RunCompletion(ctx, response, request)
@@ -196,18 +206,38 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
return
}
if tool == nil || tool.Name != "search_internet" {
if tool == nil {
debug("no tool call, done")
return
}
debug("got %q tool call", tool.Name)
response.Send(ToolChunk(tool))
err = HandleSearchTool(ctx, tool)
switch tool.Name {
case "search_web":
err = HandleSearchWebTool(ctx, tool)
if err != nil {
response.Send(ErrorChunk(err))
return
}
case "fetch_contents":
err = HandleFetchContentsTool(ctx, tool)
if err != nil {
response.Send(ErrorChunk(err))
return
}
default:
return
}
tool.Done = true
debug("finished tool call")
response.Send(ToolChunk(tool))
@@ -260,9 +290,6 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch
choice := chunk.Choices[0]
// DEBUG
debug(choice)
if choice.FinishReason == openrouter.FinishReasonContentFilter {
response.Send(ErrorChunk(errors.New("stopped due to content_filter")))

20
clean.go Normal file
View File

@@ -0,0 +1,20 @@
package main
import "strings"
var cleaner = strings.NewReplacer(
"", "-",
"—", "-",
"“", "\"",
"”", "\"",
"", "'",
)
func CleanChunk(chunk string) string {
if !CleanContent {
return chunk
}
return cleaner.Replace(chunk)
}

View File

@@ -1,23 +1,17 @@
package main
import (
"encoding/json"
"os"
)
func dump(v any) {
func debug(format string, args ...any) {
if !Debug {
return
}
b, _ := json.MarshalIndent(v, "", "\t")
os.WriteFile("debug.json", b, 0644)
log.Debugf(format+"\n", args...)
}
func debug(v any) {
if !Debug {
func debugIf(cond bool, format string, args ...any) {
if !cond {
return
}
log.Debugf("%#v\n", v)
debug(format, args)
}

19
env.go
View File

@@ -11,15 +11,28 @@ import (
var (
Debug bool
CleanContent bool
MaxIterations int
OpenRouterToken string
ExaToken string
)
func init() {
log.MustPanic(godotenv.Load())
// enable debug logs & prints
Debug = os.Getenv("DEBUG") == "true"
if Debug {
log.Warning("Debug mode enabled")
}
// de-ai assistant response content
CleanContent = os.Getenv("DEBUG") == "true"
// maximum amount of iterations per turn
if env := os.Getenv("MAX_ITERATIONS"); env != "" {
iterations, err := strconv.Atoi(env)
if err != nil {
@@ -35,11 +48,13 @@ func init() {
MaxIterations = 3
}
// openrouter token used for all completions & model list
if OpenRouterToken = os.Getenv("OPENROUTER_TOKEN"); OpenRouterToken == "" {
log.Panic(errors.New("missing openrouter token"))
}
if Debug {
log.Warning("Debug mode enabled")
// optional exa token used for search tools
if ExaToken = os.Getenv("EXA_TOKEN"); ExaToken == "" {
log.Warning("missing exa token, web search unavailable")
}
}

131
exa.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
type ExaResult struct {
Title string `json:"title"`
URL string `json:"url"`
PublishedDate string `json:"publishedDate"`
Text string `json:"text"`
Summary string `json:"summary"`
}
type ExaResults struct {
RequestID string `json:"requestId"`
Results []ExaResult `json:"results"`
}
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 {
list := make([]string, len(e.Results))
for i, result := range e.Results {
list[i] = result.String()
}
return strings.Join(list, "\n\n---\n\n")
}
func NewExaRequest(ctx context.Context, path string, data any) (*http.Request, error) {
buf, err := json.Marshal(data)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", fmt.Sprintf("https://api.exa.ai%s", path), bytes.NewReader(buf))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", ExaToken)
return req, nil
}
func RunExaRequest(req *http.Request) (*ExaResults, error) {
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result ExaResults
err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return nil, err
}
return &result, nil
}
func ExaRunSearch(ctx context.Context, args SearchWebArguments) (*ExaResults, error) {
data := map[string]any{
"query": args.Query,
"type": "auto",
"numResults": args.NumResults,
"contents": map[string]any{
"summary": map[string]any{
"query": "Summarize this page only with all information directly relevant to answering the user's question: include key facts, numbers, dates, names, definitions, steps, code or commands, and the page's stance or conclusion; omit fluff and unrelated sections.",
},
},
}
req, err := NewExaRequest(ctx, "/search", data)
if err != nil {
return nil, err
}
return RunExaRequest(req)
}
func ExaRunContents(ctx context.Context, args FetchContentsArguments) (*ExaResults, error) {
data := map[string]any{
"urls": args.URLs,
"text": map[string]any{
"maxCharacters": 8000,
},
}
req, err := NewExaRequest(ctx, "/contents", data)
if err != nil {
return nil, err
}
return RunExaRequest(req)
}

4
go.mod
View File

@@ -6,7 +6,7 @@ require (
github.com/coalaura/logger v1.5.1
github.com/go-chi/chi/v5 v5.2.2
github.com/joho/godotenv v1.5.1
github.com/revrost/go-openrouter v0.1.11-0.20250804020417-b3d94f4f6b46
github.com/revrost/go-openrouter v0.2.1
)
require (
@@ -16,6 +16,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/term v0.32.0 // indirect
)

8
go.sum
View File

@@ -22,8 +22,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/revrost/go-openrouter v0.1.11-0.20250804020417-b3d94f4f6b46 h1:Ai/eskFY6VN+0kARZEE9l3ccbwvGB9CQ6/gJfafHQs0=
github.com/revrost/go-openrouter v0.1.11-0.20250804020417-b3d94f4f6b46/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0=
github.com/revrost/go-openrouter v0.2.1 h1:4BMQ6pgYeEJq9pLl7pFbwnBabmqgUa35hGRnVHqjpA4=
github.com/revrost/go-openrouter v0.2.1/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
@@ -36,8 +36,8 @@ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -35,6 +35,7 @@ func main() {
r.Get("/-/data", func(w http.ResponseWriter, r *http.Request) {
RespondJson(w, http.StatusOK, map[string]any{
"version": Version,
"search": ExaToken != "",
"models": models,
})
})

View File

@@ -40,6 +40,10 @@ type Generation struct {
NumSearchResults *int `json:"num_search_results"`
}
func init() {
openrouter.DisableLogs()
}
func OpenRouterClient() *openrouter.Client {
return openrouter.NewClient(OpenRouterToken)
}

View File

@@ -8,6 +8,7 @@ Output Style
- Answer directly first. Use short paragraphs or bullet lists; avoid heavy formatting.
- Use fenced code blocks with language tags for code. Keep examples minimal, runnable, and focused on the user's goal.
- Prefer plain text for math and notation; show only essential steps when helpful.
- Wrap multi-line code in markdown code-blocks.
Quality Bar
- Do not invent facts or sources. If uncertain or missing data, say so and propose next steps or what info would resolve it.

View File

@@ -1,28 +0,0 @@
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.]

101
search.go
View File

@@ -10,29 +10,56 @@ import (
"github.com/revrost/go-openrouter"
)
type SearchArguments struct {
type SearchWebArguments struct {
Query string `json:"query"`
NumResults int `json:"num_results"`
}
var (
//go:embed prompts/search.txt
PromptSearch string
)
type FetchContentsArguments struct {
URLs []string `json:"urls"`
}
func GetSearchTool() []openrouter.Tool {
func GetSearchTools() []openrouter.Tool {
return []openrouter.Tool{
{
Type: openrouter.ToolTypeFunction,
Function: &openrouter.FunctionDefinition{
Name: "search_internet",
Description: "Search the internet for current information.",
Name: "search_web",
Description: "Search the web via Exa in auto mode. Returns up to 10 results with short summaries.",
Parameters: map[string]any{
"type": "object",
"required": []string{"query"},
"required": []string{"query", "num_results"},
"properties": map[string]any{
"query": map[string]string{
"query": map[string]any{
"type": "string",
"description": "A concise and specific query string.",
"description": "A concise, specific search query in natural language.",
},
"num_results": map[string]any{
"type": "integer",
"description": "Number of results to return (1-10). Default 10.",
"minimum": 1,
"maximum": 10,
},
},
"additionalProperties": false,
},
Strict: true,
},
},
{
Type: openrouter.ToolTypeFunction,
Function: &openrouter.FunctionDefinition{
Name: "fetch_contents",
Description: "Fetch page contents for one or more URLs via Exa /contents.",
Parameters: map[string]any{
"type": "object",
"required": []string{"urls"},
"properties": map[string]any{
"urls": map[string]any{
"type": "array",
"description": "List of URLs (1..N) to fetch.",
"items": map[string]any{"type": "string"},
"minItems": 1,
},
},
"additionalProperties": false,
@@ -43,8 +70,8 @@ func GetSearchTool() []openrouter.Tool {
}
}
func HandleSearchTool(ctx context.Context, tool *ToolCall) error {
var arguments SearchArguments
func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error {
var arguments SearchWebArguments
err := json.Unmarshal([]byte(tool.Args), &arguments)
if err != nil {
@@ -55,30 +82,50 @@ func HandleSearchTool(ctx context.Context, tool *ToolCall) error {
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)
results, err := ExaRunSearch(ctx, arguments)
if err != nil {
tool.Result = fmt.Sprintf("error: %v", err)
return nil
}
if len(response.Choices) == 0 {
tool.Result = "error: failed to perform search"
if len(results.Results) == 0 {
tool.Result = "error: no search results"
return nil
}
tool.Result = response.Choices[0].Message.Content.Text
tool.Result = results.String()
return nil
}
func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error {
var arguments FetchContentsArguments
err := json.Unmarshal([]byte(tool.Args), &arguments)
if err != nil {
return err
}
if len(arguments.URLs) == 0 {
return errors.New("no urls")
}
results, err := ExaRunContents(ctx, arguments)
if err != nil {
tool.Result = fmt.Sprintf("error: %v", err)
return nil
}
if len(results.Results) == 0 {
tool.Result = "error: no search results"
return nil
}
tool.Result = results.String()
return nil
}

View File

@@ -284,8 +284,8 @@ body.loading #version {
margin-top: 10px;
}
.message.has-reasoning:not(.has-text) div.text,
.message.has-tool:not(.has-text) div.text,
.message.has-reasoning:not(.has-text):not(.errored) div.text,
.message.has-tool:not(.has-text):not(.errored) div.text,
.message:not(.has-tool) .tool,
.message:not(.has-reasoning) .reasoning {
display: none;

View File

@@ -65,9 +65,9 @@
<label for="reasoning-tokens" title="Maximum amount of reasoning tokens"></label>
<input id="reasoning-tokens" type="number" min="2" max="1" step="0.05" value="0.85" />
</div>
<div class="option group">
<button id="json" class="none" title="Turn on structured json output"></button>
<button id="search" title="Turn on web-search (openrouter built-in)"></button>
<div class="option group none">
<button id="json" title="Turn on structured json output"></button>
<button id="search" title="Turn on search tools (search_web and fetch_contents)"></button>
</div>
<div class="option">
<button id="scrolling" title="Turn on auto-scrolling"></button>

View File

@@ -21,6 +21,7 @@
models = {};
let autoScrolling = false,
searchAvailable = false,
jsonMode = false,
searchTool = false;
@@ -441,6 +442,10 @@
data.statistics = this.#statistics;
}
if (!data.reasoning && !data.text && !data.tool) {
return false;
}
return data;
}
@@ -512,6 +517,7 @@
this.#tool = tool;
this.#render("tool");
this.#save();
}
addReasoning(chunk) {
@@ -687,6 +693,9 @@
$version.innerHTML = `<a href="https://github.com/coalaura/whiskr" target="_blank">whiskr</a> <a href="https://github.com/coalaura/whiskr/releases/tag/${data.version}" target="_blank">${data.version}</a>`;
}
// update search availability
searchAvailable = data.search;
// render models
$model.innerHTML = "";
@@ -805,12 +814,12 @@
}
const hasJson = tags.includes("json"),
hasTools = tags.includes("tools");
hasSearch = searchAvailable && tags.includes("tools");
$json.classList.toggle("none", !hasJson);
$search.classList.toggle("none", !hasTools);
$search.classList.toggle("none", !hasSearch);
$search.parentNode.classList.toggle("none", !hasJson && !hasTools);
$search.parentNode.classList.toggle("none", !hasJson && !hasSearch);
});
$prompt.addEventListener("change", () => {
@@ -952,7 +961,7 @@
search: searchTool,
messages: messages
.map((message) => message.getData())
.filter((data) => data?.text),
.filter(Boolean),
};
let message, generationID;
@@ -1012,6 +1021,10 @@
}
switch (chunk.type) {
case "end":
finish();
break;
case "id":
generationID = chunk.text;
@@ -1020,7 +1033,7 @@
message.setState("tooling");
message.setTool(chunk.text);
if (chunk.text.result) {
if (chunk.text.done) {
finish();
}

View File

@@ -37,6 +37,8 @@ func NewStream(w http.ResponseWriter) (*Stream, error) {
}
func (s *Stream) Send(ch Chunk) error {
debugIf(ch.Type == "error", "error: %v", ch.Text)
if err := s.en.Encode(ch); err != nil {
return err
}
@@ -60,7 +62,7 @@ func ReasoningChunk(text string) Chunk {
func TextChunk(text string) Chunk {
return Chunk{
Type: "text",
Text: text,
Text: CleanChunk(text),
}
}