diff --git a/.example.env b/.example.env index e8742b9..4590fa4 100644 --- a/.example.env +++ b/.example.env @@ -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 diff --git a/chat.go b/chat.go index 5099a10..70e778b 100644 --- a/chat.go +++ b/chat.go @@ -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,19 +206,39 @@ 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) - if err != nil { - response.Send(ErrorChunk(err)) + 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)) request.Messages = append(request.Messages, @@ -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"))) diff --git a/clean.go b/clean.go new file mode 100644 index 0000000..f66db17 --- /dev/null +++ b/clean.go @@ -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) +} diff --git a/debug.go b/debug.go index fe206f5..c672f1b 100644 --- a/debug.go +++ b/debug.go @@ -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) } diff --git a/env.go b/env.go index 9631edc..477fe72 100644 --- a/env.go +++ b/env.go @@ -10,16 +10,29 @@ import ( ) var ( - Debug bool - MaxIterations int + 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") } } diff --git a/exa.go b/exa.go new file mode 100644 index 0000000..670b740 --- /dev/null +++ b/exa.go @@ -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) +} diff --git a/go.mod b/go.mod index 57a02f8..cb8f1cd 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b65fe48..25439bd 100644 --- a/go.sum +++ b/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/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= diff --git a/main.go b/main.go index bea1283..9a110c1 100644 --- a/main.go +++ b/main.go @@ -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, }) }) diff --git a/openrouter.go b/openrouter.go index d915d70..0e136d2 100644 --- a/openrouter.go +++ b/openrouter.go @@ -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) } diff --git a/prompts/normal.txt b/prompts/normal.txt index 09e25d6..ad67ef4 100644 --- a/prompts/normal.txt +++ b/prompts/normal.txt @@ -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. diff --git a/prompts/search.txt b/prompts/search.txt deleted file mode 100644 index e2557ff..0000000 --- a/prompts/search.txt +++ /dev/null @@ -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.] \ No newline at end of file diff --git a/search.go b/search.go index f766734..e8169ca 100644 --- a/search.go +++ b/search.go @@ -10,29 +10,56 @@ import ( "github.com/revrost/go-openrouter" ) -type SearchArguments struct { - Query string `json:"query"` +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 } diff --git a/static/css/chat.css b/static/css/chat.css index 566ebd6..38b8f83 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -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; diff --git a/static/index.html b/static/index.html index 66b8c49..9e5215a 100644 --- a/static/index.html +++ b/static/index.html @@ -65,9 +65,9 @@ -