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

github tool

This commit is contained in:
Laura
2025-08-25 18:37:30 +02:00
parent b44da19987
commit 908fdb2e93
9 changed files with 307 additions and 7 deletions

View File

@@ -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** - Tags indicate if a model supports **tools**, **vision**, or **reasoning**
- Search field with fuzzy matching to quickly find models - Search field with fuzzy matching to quickly find models
- Models are listed newest -> oldest - 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 - `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 - `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 - Images attachments for vision models using simple markdown image tags
- Text/Code file attachments - Text/Code file attachments
- Reasoning effort control - Reasoning effort control
@@ -30,7 +31,6 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode
## TODO ## TODO
- tool for github repo overviews (included in search tools)
- settings - settings
- auto-retry on edit - auto-retry on edit
- ctrl+enter vs enter for sending - ctrl+enter vs enter for sending

View File

@@ -137,7 +137,7 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
request.Tools = GetSearchTools() 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.")) request.Messages = append(request.Messages, openrouter.SystemMessage(InternalToolsPrompt))
} }
for index, message := range r.Messages { for index, message := range r.Messages {
@@ -302,6 +302,13 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
response.Send(ErrorChunk(err)) response.Send(ErrorChunk(err))
return
}
case "github_repository":
err = HandleGitHubRepositoryTool(ctx, tool)
if err != nil {
response.Send(ErrorChunk(err))
return return
} }
default: default:

2
env.go
View File

@@ -15,6 +15,7 @@ type EnvTokens struct {
Secret string `json:"secret"` Secret string `json:"secret"`
OpenRouter string `json:"openrouter"` OpenRouter string `json:"openrouter"`
Exa string `json:"exa"` Exa string `json:"exa"`
GitHub string `json:"github"`
} }
type EnvSettings struct { 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.secret": {yaml.HeadComment(" server secret for signing auth tokens; auto-generated if empty")},
"$.tokens.openrouter": {yaml.HeadComment(" openrouter.ai api token (required)")}, "$.tokens.openrouter": {yaml.HeadComment(" openrouter.ai api token (required)")},
"$.tokens.exa": {yaml.HeadComment(" exa search api token (optional; used by search tools)")}, "$.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)")}, "$.settings.cleanup": {yaml.HeadComment(" normalize unicode in assistant output (optional; default: true)")},

View File

@@ -8,6 +8,8 @@ tokens:
openrouter: "" openrouter: ""
# exa search api token (optional; used by search tools) # exa search api token (optional; used by search tools)
exa: "" exa: ""
# github api token (optional; used by search tools)
github: ""
settings: settings:
# normalize unicode in assistant output (optional; default: true) # normalize unicode in assistant output (optional; default: true)

240
github.go Normal file
View File

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

4
go.sum
View File

@@ -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/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.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 h1:7bOdLPKmw0iJB1AdpN+YaWUd2XC9cwfJKDY10iaSAzI=
github.com/revrost/go-openrouter v0.2.2/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= 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= 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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=

1
internal/tools.txt Normal file
View File

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

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
_ "embed"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
@@ -27,6 +28,9 @@ type Prompt struct {
} }
var ( var (
//go:embed internal/tools.txt
InternalToolsPrompt string
Prompts []Prompt Prompts []Prompt
Templates = make(map[string]*template.Template) Templates = make(map[string]*template.Template)
) )

View File

@@ -19,6 +19,11 @@ type FetchContentsArguments struct {
URLs []string `json:"urls"` URLs []string `json:"urls"`
} }
type GitHubRepositoryArguments struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
func GetSearchTools() []openrouter.Tool { func GetSearchTools() []openrouter.Tool {
return []openrouter.Tool{ return []openrouter.Tool{
{ {
@@ -66,6 +71,29 @@ func GetSearchTools() []openrouter.Tool {
Strict: true, 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 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
}