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:
10
.example.env
10
.example.env
@@ -1,5 +1,11 @@
|
|||||||
# Your openrouter.ai token
|
# Your openrouter.ai token (required)
|
||||||
OPENROUTER_TOKEN = ""
|
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
|
MAX_ITERATIONS = 3
|
||||||
|
|
||||||
|
# Replace unicode quotes, dashes, etc. in the assistants output (optional, default: false)
|
||||||
|
CLEAN_CONTENT = true
|
||||||
|
51
chat.go
51
chat.go
@@ -17,6 +17,7 @@ type ToolCall struct {
|
|||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
@@ -98,9 +99,11 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.Tools && r.Search {
|
if model.Tools && r.Search && ExaToken != "" {
|
||||||
request.Tools = GetSearchTool()
|
request.Tools = GetSearchTools()
|
||||||
request.ToolChoice = "auto"
|
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)
|
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) {
|
func HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
debug("new chat")
|
||||||
|
|
||||||
var raw Request
|
var raw Request
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
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
|
request.Stream = true
|
||||||
|
|
||||||
// DEBUG
|
|
||||||
dump(request)
|
|
||||||
|
|
||||||
response, err := NewStream(w)
|
response, err := NewStream(w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RespondJson(w, http.StatusBadRequest, map[string]any{
|
RespondJson(w, http.StatusBadRequest, map[string]any{
|
||||||
@@ -181,12 +183,20 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug("handling request")
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
for iteration := range MaxIterations {
|
for iteration := range MaxIterations {
|
||||||
|
debug("iteration %d of %d", iteration+1, MaxIterations)
|
||||||
|
|
||||||
if iteration == MaxIterations-1 {
|
if iteration == MaxIterations-1 {
|
||||||
|
debug("no more tool calls")
|
||||||
|
|
||||||
request.Tools = nil
|
request.Tools = nil
|
||||||
request.ToolChoice = ""
|
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)
|
tool, message, err := RunCompletion(ctx, response, request)
|
||||||
@@ -196,19 +206,39 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if tool == nil || tool.Name != "search_internet" {
|
if tool == nil {
|
||||||
|
debug("no tool call, done")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug("got %q tool call", tool.Name)
|
||||||
|
|
||||||
response.Send(ToolChunk(tool))
|
response.Send(ToolChunk(tool))
|
||||||
|
|
||||||
err = HandleSearchTool(ctx, tool)
|
switch tool.Name {
|
||||||
if err != nil {
|
case "search_web":
|
||||||
response.Send(ErrorChunk(err))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tool.Done = true
|
||||||
|
|
||||||
|
debug("finished tool call")
|
||||||
|
|
||||||
response.Send(ToolChunk(tool))
|
response.Send(ToolChunk(tool))
|
||||||
|
|
||||||
request.Messages = append(request.Messages,
|
request.Messages = append(request.Messages,
|
||||||
@@ -260,9 +290,6 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch
|
|||||||
|
|
||||||
choice := chunk.Choices[0]
|
choice := chunk.Choices[0]
|
||||||
|
|
||||||
// DEBUG
|
|
||||||
debug(choice)
|
|
||||||
|
|
||||||
if choice.FinishReason == openrouter.FinishReasonContentFilter {
|
if choice.FinishReason == openrouter.FinishReasonContentFilter {
|
||||||
response.Send(ErrorChunk(errors.New("stopped due to content_filter")))
|
response.Send(ErrorChunk(errors.New("stopped due to content_filter")))
|
||||||
|
|
||||||
|
20
clean.go
Normal file
20
clean.go
Normal 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)
|
||||||
|
}
|
16
debug.go
16
debug.go
@@ -1,23 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
func debug(format string, args ...any) {
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func dump(v any) {
|
|
||||||
if !Debug {
|
if !Debug {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b, _ := json.MarshalIndent(v, "", "\t")
|
log.Debugf(format+"\n", args...)
|
||||||
os.WriteFile("debug.json", b, 0644)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func debug(v any) {
|
func debugIf(cond bool, format string, args ...any) {
|
||||||
if !Debug {
|
if !cond {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("%#v\n", v)
|
debug(format, args)
|
||||||
}
|
}
|
||||||
|
23
env.go
23
env.go
@@ -10,16 +10,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Debug bool
|
Debug bool
|
||||||
MaxIterations int
|
|
||||||
|
CleanContent bool
|
||||||
|
MaxIterations int
|
||||||
|
|
||||||
OpenRouterToken string
|
OpenRouterToken string
|
||||||
|
ExaToken string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.MustPanic(godotenv.Load())
|
log.MustPanic(godotenv.Load())
|
||||||
|
|
||||||
|
// enable debug logs & prints
|
||||||
Debug = os.Getenv("DEBUG") == "true"
|
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 != "" {
|
if env := os.Getenv("MAX_ITERATIONS"); env != "" {
|
||||||
iterations, err := strconv.Atoi(env)
|
iterations, err := strconv.Atoi(env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,11 +48,13 @@ func init() {
|
|||||||
MaxIterations = 3
|
MaxIterations = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openrouter token used for all completions & model list
|
||||||
if OpenRouterToken = os.Getenv("OPENROUTER_TOKEN"); OpenRouterToken == "" {
|
if OpenRouterToken = os.Getenv("OPENROUTER_TOKEN"); OpenRouterToken == "" {
|
||||||
log.Panic(errors.New("missing openrouter token"))
|
log.Panic(errors.New("missing openrouter token"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if Debug {
|
// optional exa token used for search tools
|
||||||
log.Warning("Debug mode enabled")
|
if ExaToken = os.Getenv("EXA_TOKEN"); ExaToken == "" {
|
||||||
|
log.Warning("missing exa token, web search unavailable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
131
exa.go
Normal file
131
exa.go
Normal 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
4
go.mod
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/coalaura/logger v1.5.1
|
github.com/coalaura/logger v1.5.1
|
||||||
github.com/go-chi/chi/v5 v5.2.2
|
github.com/go-chi/chi/v5 v5.2.2
|
||||||
github.com/joho/godotenv v1.5.1
|
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 (
|
require (
|
||||||
@@ -16,6 +16,6 @@ require (
|
|||||||
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
|
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
|
golang.org/x/term v0.32.0 // indirect
|
||||||
)
|
)
|
||||||
|
8
go.sum
8
go.sum
@@ -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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
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.1.11-0.20250804020417-b3d94f4f6b46 h1:Ai/eskFY6VN+0kARZEE9l3ccbwvGB9CQ6/gJfafHQs0=
|
github.com/revrost/go-openrouter v0.2.1 h1:4BMQ6pgYeEJq9pLl7pFbwnBabmqgUa35hGRnVHqjpA4=
|
||||||
github.com/revrost/go-openrouter v0.1.11-0.20250804020417-b3d94f4f6b46/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0=
|
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/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=
|
||||||
@@ -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.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.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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
1
main.go
1
main.go
@@ -35,6 +35,7 @@ func main() {
|
|||||||
r.Get("/-/data", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/-/data", func(w http.ResponseWriter, r *http.Request) {
|
||||||
RespondJson(w, http.StatusOK, map[string]any{
|
RespondJson(w, http.StatusOK, map[string]any{
|
||||||
"version": Version,
|
"version": Version,
|
||||||
|
"search": ExaToken != "",
|
||||||
"models": models,
|
"models": models,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@@ -40,6 +40,10 @@ type Generation struct {
|
|||||||
NumSearchResults *int `json:"num_search_results"`
|
NumSearchResults *int `json:"num_search_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
openrouter.DisableLogs()
|
||||||
|
}
|
||||||
|
|
||||||
func OpenRouterClient() *openrouter.Client {
|
func OpenRouterClient() *openrouter.Client {
|
||||||
return openrouter.NewClient(OpenRouterToken)
|
return openrouter.NewClient(OpenRouterToken)
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,7 @@ Output Style
|
|||||||
- Answer directly first. Use short paragraphs or bullet lists; avoid heavy formatting.
|
- 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.
|
- 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.
|
- Prefer plain text for math and notation; show only essential steps when helpful.
|
||||||
|
- Wrap multi-line code in markdown code-blocks.
|
||||||
|
|
||||||
Quality Bar
|
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.
|
- Do not invent facts or sources. If uncertain or missing data, say so and propose next steps or what info would resolve it.
|
||||||
|
@@ -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.]
|
|
103
search.go
103
search.go
@@ -10,29 +10,56 @@ import (
|
|||||||
"github.com/revrost/go-openrouter"
|
"github.com/revrost/go-openrouter"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SearchArguments struct {
|
type SearchWebArguments struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
|
NumResults int `json:"num_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
type FetchContentsArguments struct {
|
||||||
//go:embed prompts/search.txt
|
URLs []string `json:"urls"`
|
||||||
PromptSearch string
|
}
|
||||||
)
|
|
||||||
|
|
||||||
func GetSearchTool() []openrouter.Tool {
|
func GetSearchTools() []openrouter.Tool {
|
||||||
return []openrouter.Tool{
|
return []openrouter.Tool{
|
||||||
{
|
{
|
||||||
Type: openrouter.ToolTypeFunction,
|
Type: openrouter.ToolTypeFunction,
|
||||||
Function: &openrouter.FunctionDefinition{
|
Function: &openrouter.FunctionDefinition{
|
||||||
Name: "search_internet",
|
Name: "search_web",
|
||||||
Description: "Search the internet for current information.",
|
Description: "Search the web via Exa in auto mode. Returns up to 10 results with short summaries.",
|
||||||
Parameters: map[string]any{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": []string{"query"},
|
"required": []string{"query", "num_results"},
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"query": map[string]string{
|
"query": map[string]any{
|
||||||
"type": "string",
|
"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,
|
"additionalProperties": false,
|
||||||
@@ -43,8 +70,8 @@ func GetSearchTool() []openrouter.Tool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSearchTool(ctx context.Context, tool *ToolCall) error {
|
func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error {
|
||||||
var arguments SearchArguments
|
var arguments SearchWebArguments
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(tool.Args), &arguments)
|
err := json.Unmarshal([]byte(tool.Args), &arguments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,30 +82,50 @@ func HandleSearchTool(ctx context.Context, tool *ToolCall) error {
|
|||||||
return errors.New("no search query")
|
return errors.New("no search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
request := openrouter.ChatCompletionRequest{
|
results, err := ExaRunSearch(ctx, arguments)
|
||||||
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 {
|
if err != nil {
|
||||||
tool.Result = fmt.Sprintf("error: %v", err)
|
tool.Result = fmt.Sprintf("error: %v", err)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(response.Choices) == 0 {
|
if len(results.Results) == 0 {
|
||||||
tool.Result = "error: failed to perform search"
|
tool.Result = "error: no search results"
|
||||||
|
|
||||||
return nil
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -284,8 +284,8 @@ body.loading #version {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.has-reasoning:not(.has-text) div.text,
|
.message.has-reasoning:not(.has-text):not(.errored) div.text,
|
||||||
.message.has-tool:not(.has-text) div.text,
|
.message.has-tool:not(.has-text):not(.errored) div.text,
|
||||||
.message:not(.has-tool) .tool,
|
.message:not(.has-tool) .tool,
|
||||||
.message:not(.has-reasoning) .reasoning {
|
.message:not(.has-reasoning) .reasoning {
|
||||||
display: none;
|
display: none;
|
||||||
|
@@ -65,9 +65,9 @@
|
|||||||
<label for="reasoning-tokens" title="Maximum amount of reasoning tokens"></label>
|
<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" />
|
<input id="reasoning-tokens" type="number" min="2" max="1" step="0.05" value="0.85" />
|
||||||
</div>
|
</div>
|
||||||
<div class="option group">
|
<div class="option group none">
|
||||||
<button id="json" class="none" title="Turn on structured json output"></button>
|
<button id="json" title="Turn on structured json output"></button>
|
||||||
<button id="search" title="Turn on web-search (openrouter built-in)"></button>
|
<button id="search" title="Turn on search tools (search_web and fetch_contents)"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="option">
|
<div class="option">
|
||||||
<button id="scrolling" title="Turn on auto-scrolling"></button>
|
<button id="scrolling" title="Turn on auto-scrolling"></button>
|
||||||
|
@@ -21,6 +21,7 @@
|
|||||||
models = {};
|
models = {};
|
||||||
|
|
||||||
let autoScrolling = false,
|
let autoScrolling = false,
|
||||||
|
searchAvailable = false,
|
||||||
jsonMode = false,
|
jsonMode = false,
|
||||||
searchTool = false;
|
searchTool = false;
|
||||||
|
|
||||||
@@ -441,6 +442,10 @@
|
|||||||
data.statistics = this.#statistics;
|
data.statistics = this.#statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.reasoning && !data.text && !data.tool) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +517,7 @@
|
|||||||
this.#tool = tool;
|
this.#tool = tool;
|
||||||
|
|
||||||
this.#render("tool");
|
this.#render("tool");
|
||||||
|
this.#save();
|
||||||
}
|
}
|
||||||
|
|
||||||
addReasoning(chunk) {
|
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>`;
|
$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
|
// render models
|
||||||
$model.innerHTML = "";
|
$model.innerHTML = "";
|
||||||
|
|
||||||
@@ -805,12 +814,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasJson = tags.includes("json"),
|
const hasJson = tags.includes("json"),
|
||||||
hasTools = tags.includes("tools");
|
hasSearch = searchAvailable && tags.includes("tools");
|
||||||
|
|
||||||
$json.classList.toggle("none", !hasJson);
|
$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", () => {
|
$prompt.addEventListener("change", () => {
|
||||||
@@ -952,7 +961,7 @@
|
|||||||
search: searchTool,
|
search: searchTool,
|
||||||
messages: messages
|
messages: messages
|
||||||
.map((message) => message.getData())
|
.map((message) => message.getData())
|
||||||
.filter((data) => data?.text),
|
.filter(Boolean),
|
||||||
};
|
};
|
||||||
|
|
||||||
let message, generationID;
|
let message, generationID;
|
||||||
@@ -1012,6 +1021,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (chunk.type) {
|
switch (chunk.type) {
|
||||||
|
case "end":
|
||||||
|
finish();
|
||||||
|
|
||||||
|
break;
|
||||||
case "id":
|
case "id":
|
||||||
generationID = chunk.text;
|
generationID = chunk.text;
|
||||||
|
|
||||||
@@ -1020,7 +1033,7 @@
|
|||||||
message.setState("tooling");
|
message.setState("tooling");
|
||||||
message.setTool(chunk.text);
|
message.setTool(chunk.text);
|
||||||
|
|
||||||
if (chunk.text.result) {
|
if (chunk.text.done) {
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -37,6 +37,8 @@ func NewStream(w http.ResponseWriter) (*Stream, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) Send(ch Chunk) error {
|
func (s *Stream) Send(ch Chunk) error {
|
||||||
|
debugIf(ch.Type == "error", "error: %v", ch.Text)
|
||||||
|
|
||||||
if err := s.en.Encode(ch); err != nil {
|
if err := s.en.Encode(ch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -60,7 +62,7 @@ func ReasoningChunk(text string) Chunk {
|
|||||||
func TextChunk(text string) Chunk {
|
func TextChunk(text string) Chunk {
|
||||||
return Chunk{
|
return Chunk{
|
||||||
Type: "text",
|
Type: "text",
|
||||||
Text: text,
|
Text: CleanChunk(text),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user