diff --git a/README.md b/README.md index 1dd7d7b..4bee32d 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,10 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode - Tags indicate if a model supports **tools**, **vision**, or **reasoning** - Search field with fuzzy matching to quickly find models - Models are listed newest -> oldest -- Web search tools (set the `EXA_TOKEN` to enable): +- Web search tools (set the `tokens.exa` to enable): - `search_web`: search via Exa in auto mode; returns up to 10 results with short summaries - `fetch_contents`: fetch page contents for one or more URLs via Exa /contents + - `github_repository`: get a quick overview of a GitHub repository (repo info, up to 20 branches, top-level files/dirs, and the README) without cloning (optionally set `tokens.github`) - Images attachments for vision models using simple markdown image tags - Text/Code file attachments - Reasoning effort control @@ -30,7 +31,6 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode ## TODO -- tool for github repo overviews (included in search tools) - settings - auto-retry on edit - ctrl+enter vs enter for sending diff --git a/chat.go b/chat.go index 88339ef..2cf7362 100644 --- a/chat.go +++ b/chat.go @@ -137,7 +137,7 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { 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.")) + request.Messages = append(request.Messages, openrouter.SystemMessage(InternalToolsPrompt)) } for index, message := range r.Messages { @@ -302,6 +302,13 @@ func HandleChat(w http.ResponseWriter, r *http.Request) { if err != nil { response.Send(ErrorChunk(err)) + return + } + case "github_repository": + err = HandleGitHubRepositoryTool(ctx, tool) + if err != nil { + response.Send(ErrorChunk(err)) + return } default: diff --git a/env.go b/env.go index c0cf1da..904de75 100644 --- a/env.go +++ b/env.go @@ -15,6 +15,7 @@ type EnvTokens struct { Secret string `json:"secret"` OpenRouter string `json:"openrouter"` Exa string `json:"exa"` + GitHub string `json:"github"` } type EnvSettings struct { @@ -119,6 +120,7 @@ func (e *Environment) Store() error { "$.tokens.secret": {yaml.HeadComment(" server secret for signing auth tokens; auto-generated if empty")}, "$.tokens.openrouter": {yaml.HeadComment(" openrouter.ai api token (required)")}, "$.tokens.exa": {yaml.HeadComment(" exa search api token (optional; used by search tools)")}, + "$.tokens.github": {yaml.HeadComment(" github api token (optional; used by search tools)")}, "$.settings.cleanup": {yaml.HeadComment(" normalize unicode in assistant output (optional; default: true)")}, diff --git a/example.config.yml b/example.config.yml index aac5508..dc2d1d7 100644 --- a/example.config.yml +++ b/example.config.yml @@ -8,6 +8,8 @@ tokens: openrouter: "" # exa search api token (optional; used by search tools) exa: "" + # github api token (optional; used by search tools) + github: "" settings: # normalize unicode in assistant output (optional; default: true) diff --git a/github.go b/github.go new file mode 100644 index 0000000..a7967bd --- /dev/null +++ b/github.go @@ -0,0 +1,240 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strings" + "sync" +) + +type GitHubRepo struct { + Name string `json:"name"` + HtmlURL string `json:"html_url"` + Description string `json:"description"` + Stargazers int `json:"stargazers_count"` + Forks int `json:"forks_count"` + Visibility string `json:"visibility"` + DefaultBranch string `json:"default_branch"` +} + +type GitHubContent struct { + Type string `json:"type"` + Name string `json:"name"` +} + +type GitHubReadme struct { + Path string `json:"path"` + Content string `json:"content"` + Encoding string `json:"encoding"` +} + +func (r *GitHubReadme) AsText() (string, error) { + if r.Encoding == "base64" { + content, err := base64.StdEncoding.DecodeString(r.Content) + if err != nil { + return "", err + } + + return string(content), nil + } + + return r.Content, nil +} + +func NewGitHubRequest(ctx context.Context, path string) (*http.Request, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com%s", path), nil) + if err != nil { + return nil, err + } + + req = req.WithContext(ctx) + + req.Header.Set("Accept", "application/vnd.github+json") + + if env.Tokens.GitHub != "" { + req.Header.Set("Authorization", "Bearer "+env.Tokens.GitHub) + } + + return req, nil +} + +func GitHubRepositoryJson(ctx context.Context, owner, repo string) (*GitHubRepo, error) { + req, err := NewGitHubRequest(ctx, fmt.Sprintf("/repos/%s/%s", owner, repo)) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var response GitHubRepo + + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + + if response.Name == "" { + return nil, errors.New("error getting data") + } + + if response.Description == "" { + response.Description = "(none)" + } + + return &response, nil +} + +func GitHubRepositoryReadmeJson(ctx context.Context, owner, repo, branch string) (*GitHubReadme, error) { + req, err := NewGitHubRequest(ctx, fmt.Sprintf("/repos/%s/%s/readme?ref=%s", owner, repo, branch)) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var response GitHubReadme + + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + + return &response, nil +} + +func GitHubRepositoryContentsJson(ctx context.Context, owner, repo, branch string) ([]GitHubContent, error) { + req, err := NewGitHubRequest(ctx, fmt.Sprintf("/repos/%s/%s/contents?ref=%s", owner, repo, branch)) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var response []GitHubContent + + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + + return response, nil +} + +func RepoOverview(ctx context.Context, arguments GitHubRepositoryArguments) (string, error) { + repository, err := GitHubRepositoryJson(ctx, arguments.Owner, arguments.Repo) + if err != nil { + return "", err + } + + var ( + wg sync.WaitGroup + + readmeMarkdown string + directories []string + files []string + ) + + // fetch readme + wg.Add(1) + + go func() { + defer wg.Done() + + readme, err := GitHubRepositoryReadmeJson(ctx, arguments.Owner, arguments.Repo, repository.DefaultBranch) + if err != nil { + log.Warningf("failed to get repository readme: %v\n", err) + + return + } + + markdown, err := readme.AsText() + if err != nil { + log.Warningf("failed to decode repository readme: %v\n", err) + + return + } + + readmeMarkdown = markdown + }() + + // fetch contents + wg.Add(1) + + go func() { + defer wg.Done() + + contents, err := GitHubRepositoryContentsJson(ctx, arguments.Owner, arguments.Repo, repository.DefaultBranch) + if err != nil { + log.Warningf("failed to get repository contents: %v\n", err) + + return + } + + for _, content := range contents { + switch content.Type { + case "dir": + directories = append(directories, content.Name) + case "file": + files = append(files, content.Name) + } + } + + sort.Strings(directories) + sort.Strings(files) + }() + + // wait and combine results + wg.Wait() + + var builder strings.Builder + + fmt.Fprintf(&builder, "### %s (%s)\n", repository.Name, repository.Visibility) + fmt.Fprintf(&builder, "- URL: %s\n", repository.HtmlURL) + fmt.Fprintf(&builder, "- Description: %s\n", strings.ReplaceAll(repository.Description, "\n", " ")) + fmt.Fprintf(&builder, "- Default branch: %s\n", repository.DefaultBranch) + fmt.Fprintf(&builder, "- Stars: %d | Forks: %d\n", repository.Stargazers, repository.Forks) + + builder.WriteString("\n### Top-level files and directories\n") + + if len(directories) == 0 && len(files) == 0 { + builder.WriteString("*No entries or insufficient permissions.*\n") + } else { + for _, directory := range directories { + fmt.Fprintf(&builder, "- [D] %s\n", directory) + } + + for _, file := range files { + fmt.Fprintf(&builder, "- [F] %s\n", file) + } + } + + builder.WriteString("\n### README\n") + + if readmeMarkdown == "" { + builder.WriteString("*No README found or could not load.*\n") + } else { + builder.WriteString(readmeMarkdown) + } + + return builder.String(), nil +} diff --git a/go.sum b/go.sum index 260ccb2..95f07c6 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,6 @@ 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.2.1 h1:4BMQ6pgYeEJq9pLl7pFbwnBabmqgUa35hGRnVHqjpA4= -github.com/revrost/go-openrouter v0.2.1/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= github.com/revrost/go-openrouter v0.2.2 h1:7bOdLPKmw0iJB1AdpN+YaWUd2XC9cwfJKDY10iaSAzI= github.com/revrost/go-openrouter v0.2.2/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -33,8 +31,6 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= diff --git a/internal/tools.txt b/internal/tools.txt new file mode 100644 index 0000000..4b587ef --- /dev/null +++ b/internal/tools.txt @@ -0,0 +1 @@ +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. Use `github_repository` with `owner` (string) and `repo` (string) to get a quick overview of a GitHub repository (repo info, up to 20 branches, top-level files/dirs, and the README) without cloning. Always specify all parameters for each tool call. Call only one tool per response. \ No newline at end of file diff --git a/prompts.go b/prompts.go index ee5b341..b995b34 100644 --- a/prompts.go +++ b/prompts.go @@ -2,6 +2,7 @@ package main import ( "bytes" + _ "embed" "fmt" "io" "io/fs" @@ -27,6 +28,9 @@ type Prompt struct { } var ( + //go:embed internal/tools.txt + InternalToolsPrompt string + Prompts []Prompt Templates = make(map[string]*template.Template) ) diff --git a/search.go b/search.go index 3c1bd99..4930e73 100644 --- a/search.go +++ b/search.go @@ -19,6 +19,11 @@ type FetchContentsArguments struct { URLs []string `json:"urls"` } +type GitHubRepositoryArguments struct { + Owner string `json:"owner"` + Repo string `json:"repo"` +} + func GetSearchTools() []openrouter.Tool { return []openrouter.Tool{ { @@ -66,6 +71,29 @@ func GetSearchTools() []openrouter.Tool { Strict: true, }, }, + { + Type: openrouter.ToolTypeFunction, + Function: &openrouter.FunctionDefinition{ + Name: "github_repository", + Description: "Get a quick overview of a GitHub repository without cloning: repo info, up to 20 branches (popular first), top-level files/dirs, and the README.", + Parameters: map[string]any{ + "type": "object", + "required": []string{"owner", "repo"}, + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "GitHub username or organization (e.g., 'torvalds').", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name (e.g., 'linux').", + }, + }, + "additionalProperties": false, + }, + Strict: true, + }, + }, } } @@ -128,3 +156,23 @@ func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error { return nil } + +func HandleGitHubRepositoryTool(ctx context.Context, tool *ToolCall) error { + var arguments GitHubRepositoryArguments + + err := json.Unmarshal([]byte(tool.Args), &arguments) + if err != nil { + return err + } + + result, err := RepoOverview(ctx, arguments) + if err != nil { + tool.Result = fmt.Sprintf("error: %v", err) + + return nil + } + + tool.Result = result + + return nil +}