mirror of
https://github.com/ollama/ollama.git
synced 2025-11-12 08:57:18 +01:00
* app: add code for macOS and Windows apps under 'app' * app: add readme * app: windows and linux only for now * ci: fix ui CI validation --------- Co-authored-by: jmorganca <jmorganca@gmail.com>
1825 lines
54 KiB
Go
1825 lines
54 KiB
Go
//go:build windows || darwin
|
||
|
||
// package ui implements a chat interface for Ollama
|
||
package ui
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httputil"
|
||
"net/url"
|
||
"os"
|
||
"runtime"
|
||
"runtime/debug"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/ollama/ollama/api"
|
||
"github.com/ollama/ollama/app/auth"
|
||
"github.com/ollama/ollama/app/server"
|
||
"github.com/ollama/ollama/app/store"
|
||
"github.com/ollama/ollama/app/tools"
|
||
"github.com/ollama/ollama/app/types/not"
|
||
"github.com/ollama/ollama/app/ui/responses"
|
||
"github.com/ollama/ollama/app/version"
|
||
ollamaAuth "github.com/ollama/ollama/auth"
|
||
"github.com/ollama/ollama/envconfig"
|
||
"github.com/ollama/ollama/types/model"
|
||
_ "github.com/tkrajina/typescriptify-golang-structs/typescriptify"
|
||
)
|
||
|
||
//go:generate tscriptify -package=github.com/ollama/ollama/app/ui/responses -target=./app/codegen/gotypes.gen.ts responses/types.go
|
||
//go:generate npm --prefix ./app run build
|
||
|
||
var CORS = envconfig.Bool("OLLAMA_CORS")
|
||
|
||
// OllamaDotCom returns the URL for ollama.com, allowing override via environment variable
|
||
var OllamaDotCom = func() string {
|
||
if url := os.Getenv("OLLAMA_DOT_COM_URL"); url != "" {
|
||
return url
|
||
}
|
||
return "https://ollama.com"
|
||
}()
|
||
|
||
type statusRecorder struct {
|
||
http.ResponseWriter
|
||
code int
|
||
}
|
||
|
||
func (r *statusRecorder) Written() bool {
|
||
return r.code != 0
|
||
}
|
||
|
||
func (r *statusRecorder) WriteHeader(code int) {
|
||
r.code = code
|
||
r.ResponseWriter.WriteHeader(code)
|
||
}
|
||
|
||
func (r *statusRecorder) Status() int {
|
||
if r.code == 0 {
|
||
return http.StatusOK
|
||
}
|
||
return r.code
|
||
}
|
||
|
||
func (r *statusRecorder) Flush() {
|
||
if flusher, ok := r.ResponseWriter.(http.Flusher); ok {
|
||
flusher.Flush()
|
||
}
|
||
}
|
||
|
||
// Event is a string that represents the type of event being sent to the
|
||
// client. It is used in the Server-Sent Events (SSE) protocol to identify
|
||
// the type of data being sent.
|
||
// The client (template) will use this type in the sse event listener to
|
||
// determine how to handle the incoming data. It will also be used in the
|
||
// sse-swap htmx event listener to determine how to handle the incoming data.
|
||
type Event string
|
||
|
||
const (
|
||
EventChat Event = "chat"
|
||
EventComplete Event = "complete"
|
||
EventLoading Event = "loading"
|
||
EventToolResult Event = "tool_result" // Used for both tool calls and their results
|
||
EventThinking Event = "thinking"
|
||
EventToolCall Event = "tool_call"
|
||
EventDownload Event = "download"
|
||
)
|
||
|
||
type Server struct {
|
||
Logger *slog.Logger
|
||
Restart func()
|
||
Token string
|
||
Store *store.Store
|
||
ToolRegistry *tools.Registry
|
||
Tools bool // if true, the server will use single-turn tools to fulfill the user's request
|
||
WebSearch bool // if true, the server will use single-turn browser tool to fulfill the user's request
|
||
Agent bool // if true, the server will use multi-turn tools to fulfill the user's request
|
||
WorkingDir string // Working directory for all agent operations
|
||
|
||
// Dev is true if the server is running in development mode
|
||
Dev bool
|
||
}
|
||
|
||
func (s *Server) log() *slog.Logger {
|
||
if s.Logger == nil {
|
||
return slog.Default()
|
||
}
|
||
return s.Logger
|
||
}
|
||
|
||
// ollamaProxy creates a reverse proxy handler to the Ollama server
|
||
func (s *Server) ollamaProxy() http.Handler {
|
||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||
if ollamaHost == "" {
|
||
ollamaHost = "http://127.0.0.1:11434"
|
||
}
|
||
|
||
if !strings.HasPrefix(ollamaHost, "http://") && !strings.HasPrefix(ollamaHost, "https://") {
|
||
ollamaHost = "http://" + ollamaHost
|
||
}
|
||
|
||
target, err := url.Parse(ollamaHost)
|
||
if err != nil {
|
||
s.log().Error("failed to parse OLLAMA_HOST", "error", err, "host", ollamaHost)
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
http.Error(w, "failed to configure proxy", http.StatusInternalServerError)
|
||
})
|
||
}
|
||
|
||
s.log().Info("configuring ollama proxy", "target", target.String())
|
||
|
||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||
|
||
originalDirector := proxy.Director
|
||
proxy.Director = func(req *http.Request) {
|
||
originalDirector(req)
|
||
req.Host = target.Host
|
||
s.log().Debug("proxying request", "method", req.Method, "path", req.URL.Path, "target", target.Host)
|
||
}
|
||
|
||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||
s.log().Error("proxy error", "error", err, "path", r.URL.Path, "target", target.String())
|
||
http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway)
|
||
}
|
||
|
||
return proxy
|
||
}
|
||
|
||
type errHandlerFunc func(http.ResponseWriter, *http.Request) error
|
||
|
||
func (s *Server) Handler() http.Handler {
|
||
handle := func(f errHandlerFunc) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// Add CORS headers for dev work
|
||
if CORS() {
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||
|
||
// Handle preflight requests
|
||
if r.Method == "OPTIONS" {
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Don't check for token in development mode
|
||
if !s.Dev {
|
||
cookie, err := r.Cookie("token")
|
||
if err != nil {
|
||
w.WriteHeader(http.StatusForbidden)
|
||
json.NewEncoder(w).Encode(map[string]string{"error": "Token is required"})
|
||
return
|
||
}
|
||
|
||
if cookie.Value != s.Token {
|
||
w.WriteHeader(http.StatusForbidden)
|
||
json.NewEncoder(w).Encode(map[string]string{"error": "Token is required"})
|
||
return
|
||
}
|
||
}
|
||
|
||
sw := &statusRecorder{ResponseWriter: w}
|
||
|
||
log := s.log()
|
||
level := slog.LevelInfo
|
||
start := time.Now()
|
||
requestID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||
|
||
defer func() {
|
||
p := recover()
|
||
if p != nil {
|
||
log = log.With("panic", p, "request_id", requestID)
|
||
level = slog.LevelError
|
||
|
||
// Handle panic with user-friendly error
|
||
if !sw.Written() {
|
||
s.handleError(sw, fmt.Errorf("internal server error"))
|
||
}
|
||
}
|
||
|
||
log.Log(r.Context(), level, "site.serveHTTP",
|
||
"http.method", r.Method,
|
||
"http.path", r.URL.Path,
|
||
"http.pattern", r.Pattern,
|
||
"http.status", sw.Status(),
|
||
"http.d", time.Since(start),
|
||
"request_id", requestID,
|
||
"version", version.Version,
|
||
)
|
||
|
||
// let net/http.Server deal with panics
|
||
if p != nil {
|
||
panic(p)
|
||
}
|
||
}()
|
||
|
||
w.Header().Set("X-Frame-Options", "DENY")
|
||
w.Header().Set("X-Version", version.Version)
|
||
w.Header().Set("X-Request-ID", requestID)
|
||
|
||
ctx := r.Context()
|
||
if err := f(sw, r); err != nil {
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
level = slog.LevelError
|
||
log = log.With("error", err)
|
||
s.handleError(sw, err)
|
||
}
|
||
})
|
||
}
|
||
|
||
mux := http.NewServeMux()
|
||
|
||
// CORS is handled in `handle`, but we have to match on OPTIONS to handle preflight requests
|
||
mux.Handle("OPTIONS /", handle(func(w http.ResponseWriter, r *http.Request) error {
|
||
return nil
|
||
}))
|
||
|
||
// API routes - handle first to take precedence
|
||
mux.Handle("GET /api/v1/chats", handle(s.listChats))
|
||
mux.Handle("GET /api/v1/chat/{id}", handle(s.getChat))
|
||
mux.Handle("POST /api/v1/chat/{id}", handle(s.chat))
|
||
mux.Handle("DELETE /api/v1/chat/{id}", handle(s.deleteChat))
|
||
mux.Handle("POST /api/v1/create-chat", handle(s.createChat))
|
||
mux.Handle("PUT /api/v1/chat/{id}/rename", handle(s.renameChat))
|
||
|
||
mux.Handle("GET /api/v1/inference-compute", handle(s.getInferenceCompute))
|
||
mux.Handle("POST /api/v1/model/upstream", handle(s.modelUpstream))
|
||
mux.Handle("GET /api/v1/settings", handle(s.getSettings))
|
||
mux.Handle("POST /api/v1/settings", handle(s.settings))
|
||
|
||
// Ollama proxy endpoints
|
||
ollamaProxy := s.ollamaProxy()
|
||
mux.Handle("GET /api/tags", ollamaProxy)
|
||
mux.Handle("POST /api/show", ollamaProxy)
|
||
|
||
mux.Handle("GET /api/v1/me", handle(s.me))
|
||
mux.Handle("POST /api/v1/disconnect", handle(s.disconnect))
|
||
mux.Handle("GET /api/v1/connect", handle(s.connectURL))
|
||
mux.Handle("GET /api/v1/health", handle(s.health))
|
||
|
||
// React app - catch all non-API routes and serve the React app
|
||
mux.Handle("GET /", s.appHandler())
|
||
mux.Handle("PUT /", s.appHandler())
|
||
mux.Handle("POST /", s.appHandler())
|
||
mux.Handle("PATCH /", s.appHandler())
|
||
mux.Handle("DELETE /", s.appHandler())
|
||
|
||
return mux
|
||
}
|
||
|
||
// handleError renders appropriate error responses based on request type
|
||
func (s *Server) handleError(w http.ResponseWriter, e error) {
|
||
// Preserve CORS headers for API requests
|
||
if CORS() {
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
json.NewEncoder(w).Encode(map[string]string{"error": e.Error()})
|
||
}
|
||
|
||
// userAgentTransport is a custom RoundTripper that adds the User-Agent header to all requests
|
||
type userAgentTransport struct {
|
||
base http.RoundTripper
|
||
}
|
||
|
||
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||
// Clone the request to avoid mutating the original
|
||
r := req.Clone(req.Context())
|
||
r.Header.Set("User-Agent", userAgent())
|
||
return t.base.RoundTrip(r)
|
||
}
|
||
|
||
// httpClient returns an HTTP client that automatically adds the User-Agent header
|
||
func (s *Server) httpClient() *http.Client {
|
||
return &http.Client{
|
||
Timeout: 10 * time.Second,
|
||
Transport: &userAgentTransport{
|
||
base: http.DefaultTransport,
|
||
},
|
||
}
|
||
}
|
||
|
||
// doSelfSigned sends a self-signed request to the ollama.com API
|
||
func (s *Server) doSelfSigned(ctx context.Context, method, path string) (*http.Response, error) {
|
||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||
// Form the string to sign: METHOD,PATH?ts=TIMESTAMP
|
||
signString := fmt.Sprintf("%s,%s?ts=%s", method, path, timestamp)
|
||
signature, err := ollamaAuth.Sign(ctx, []byte(signString))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to sign request: %w", err)
|
||
}
|
||
|
||
endpoint := fmt.Sprintf("%s%s?ts=%s", OllamaDotCom, path, timestamp)
|
||
req, err := http.NewRequestWithContext(ctx, method, endpoint, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||
}
|
||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signature))
|
||
|
||
return s.httpClient().Do(req)
|
||
}
|
||
|
||
// UserData fetches user data from ollama.com API for the current ollama key
|
||
func (s *Server) UserData(ctx context.Context) (*responses.User, error) {
|
||
resp, err := s.doSelfSigned(ctx, http.MethodPost, "/api/me")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to call ollama.com/api/me: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||
}
|
||
|
||
var user responses.User
|
||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||
return nil, fmt.Errorf("failed to parse user response: %w", err)
|
||
}
|
||
|
||
user.AvatarURL = fmt.Sprintf("%s/%s", OllamaDotCom, user.AvatarURL)
|
||
|
||
storeUser := store.User{
|
||
Name: user.Name,
|
||
Email: user.Email,
|
||
Plan: user.Plan,
|
||
}
|
||
if err := s.Store.SetUser(storeUser); err != nil {
|
||
s.log().Warn("failed to cache user data", "error", err)
|
||
}
|
||
|
||
return &user, nil
|
||
}
|
||
|
||
func waitForServer(ctx context.Context) error {
|
||
timeout := time.Now().Add(10 * time.Second)
|
||
// TODO: this avoids an error on first load of the app
|
||
// however we should either show a loading state or
|
||
// wait for the Ollama server to be ready before redirecting
|
||
for {
|
||
c, err := api.ClientFromEnvironment()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := c.Version(ctx); err == nil {
|
||
break
|
||
}
|
||
if time.Now().After(timeout) {
|
||
return fmt.Errorf("timeout waiting for Ollama server to be ready")
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) createChat(w http.ResponseWriter, r *http.Request) error {
|
||
waitForServer(r.Context())
|
||
|
||
id, err := uuid.NewV7()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to generate chat ID: %w", err)
|
||
}
|
||
|
||
json.NewEncoder(w).Encode(map[string]string{"id": id.String()})
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) listChats(w http.ResponseWriter, r *http.Request) error {
|
||
chats, _ := s.Store.Chats()
|
||
|
||
chatInfos := make([]responses.ChatInfo, len(chats))
|
||
for i, chat := range chats {
|
||
chatInfos[i] = chatInfoFromChat(chat)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(responses.ChatsResponse{ChatInfos: chatInfos})
|
||
return nil
|
||
}
|
||
|
||
// checkModelUpstream makes a HEAD request to the Ollama registry to get the upstream digest and push time
|
||
func (s *Server) checkModelUpstream(ctx context.Context, modelName string, timeout time.Duration) (string, int64, error) {
|
||
// Create a context with timeout for the registry check
|
||
checkCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
|
||
// Parse model name to get namespace, model, and tag
|
||
parts := strings.Split(modelName, ":")
|
||
name := parts[0]
|
||
tag := "latest"
|
||
if len(parts) > 1 {
|
||
tag = parts[1]
|
||
}
|
||
|
||
if !strings.Contains(name, "/") {
|
||
// If the model name does not contain a slash, assume it's a library model
|
||
name = "library/" + name
|
||
}
|
||
|
||
// Check the model in the Ollama registry using HEAD request
|
||
url := OllamaDotCom + "/v2/" + name + "/manifests/" + tag
|
||
req, err := http.NewRequestWithContext(checkCtx, "HEAD", url, nil)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
|
||
httpClient := s.httpClient()
|
||
httpClient.Timeout = timeout
|
||
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", 0, fmt.Errorf("registry returned status %d", resp.StatusCode)
|
||
}
|
||
|
||
digest := resp.Header.Get("ollama-content-digest")
|
||
if digest == "" {
|
||
return "", 0, fmt.Errorf("no digest header found")
|
||
}
|
||
|
||
var pushTime int64
|
||
if pushTimeStr := resp.Header.Get("ollama-push-time"); pushTimeStr != "" {
|
||
if pt, err := strconv.ParseInt(pushTimeStr, 10, 64); err == nil {
|
||
pushTime = pt
|
||
}
|
||
}
|
||
|
||
return digest, pushTime, nil
|
||
}
|
||
|
||
// isNetworkError checks if an error string contains common network/connection error patterns
|
||
func isNetworkError(errStr string) bool {
|
||
networkErrorPatterns := []string{
|
||
"connection refused",
|
||
"no such host",
|
||
"timeout",
|
||
"network is unreachable",
|
||
"connection reset",
|
||
"connection timed out",
|
||
"temporary failure",
|
||
"dial tcp",
|
||
"i/o timeout",
|
||
"context deadline exceeded",
|
||
"broken pipe",
|
||
}
|
||
|
||
for _, pattern := range networkErrorPatterns {
|
||
if strings.Contains(errStr, pattern) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
var ErrNetworkOffline = errors.New("network is offline")
|
||
|
||
func (s *Server) getError(err error) responses.ErrorEvent {
|
||
var sErr api.AuthorizationError
|
||
if errors.As(err, &sErr) && sErr.StatusCode == http.StatusUnauthorized {
|
||
return responses.ErrorEvent{
|
||
EventName: "error",
|
||
Error: "Could not verify you are signed in. Please sign in and try again.",
|
||
Code: "cloud_unauthorized",
|
||
}
|
||
}
|
||
|
||
errStr := err.Error()
|
||
|
||
switch {
|
||
case strings.Contains(errStr, "402"):
|
||
return responses.ErrorEvent{
|
||
EventName: "error",
|
||
Error: "You've reached your usage limit, please upgrade to continue",
|
||
Code: "usage_limit_upgrade",
|
||
}
|
||
case strings.HasPrefix(errStr, "pull model manifest") && isNetworkError(errStr):
|
||
return responses.ErrorEvent{
|
||
EventName: "error",
|
||
Error: "Unable to download model. Please check your internet connection to download the model for offline use.",
|
||
Code: "offline_download_error",
|
||
}
|
||
case errors.Is(err, ErrNetworkOffline) || strings.Contains(errStr, "operation timed out"):
|
||
return responses.ErrorEvent{
|
||
EventName: "error",
|
||
Error: "Connection lost",
|
||
Code: "turbo_connection_lost",
|
||
}
|
||
}
|
||
return responses.ErrorEvent{
|
||
EventName: "error",
|
||
Error: err.Error(),
|
||
}
|
||
}
|
||
|
||
func (s *Server) browserState(chat *store.Chat) (*responses.BrowserStateData, bool) {
|
||
if len(chat.BrowserState) > 0 {
|
||
var st responses.BrowserStateData
|
||
if err := json.Unmarshal(chat.BrowserState, &st); err == nil {
|
||
return &st, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// reconstructBrowserState (legacy): return the latest full browser state stored in messages.
|
||
func reconstructBrowserState(messages []store.Message, defaultViewTokens int) *responses.BrowserStateData {
|
||
for i := len(messages) - 1; i >= 0; i-- {
|
||
msg := messages[i]
|
||
if msg.ToolResult == nil {
|
||
continue
|
||
}
|
||
var st responses.BrowserStateData
|
||
if err := json.Unmarshal(*msg.ToolResult, &st); err == nil {
|
||
if len(st.PageStack) > 0 || len(st.URLToPage) > 0 {
|
||
if st.ViewTokens == 0 {
|
||
st.ViewTokens = defaultViewTokens
|
||
}
|
||
return &st
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) chat(w http.ResponseWriter, r *http.Request) error {
|
||
w.Header().Set("Content-Type", "text/jsonl")
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
w.Header().Set("Connection", "keep-alive")
|
||
w.Header().Set("Transfer-Encoding", "chunked")
|
||
|
||
flusher, ok := w.(http.Flusher)
|
||
if !ok {
|
||
return errors.New("streaming not supported")
|
||
}
|
||
|
||
if r.Method != "POST" {
|
||
return not.Found
|
||
}
|
||
|
||
cid := r.PathValue("id")
|
||
createdChat := false
|
||
|
||
// if cid is the literal string "new", then we create a new chat before
|
||
// performing our normal actions
|
||
if cid == "new" {
|
||
u, err := uuid.NewV7()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to generate new chat id: %w", err)
|
||
}
|
||
cid = u.String()
|
||
createdChat = true
|
||
}
|
||
|
||
var req responses.ChatRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
fmt.Fprintf(os.Stderr, "error unmarshalling body: %v\n", err)
|
||
return fmt.Errorf("invalid request body: %w", err)
|
||
}
|
||
|
||
if req.Model == "" {
|
||
return fmt.Errorf("empty model")
|
||
}
|
||
|
||
// Don't allow empty messages unless forceUpdate is true
|
||
if req.Prompt == "" && !req.ForceUpdate {
|
||
return fmt.Errorf("empty message")
|
||
}
|
||
|
||
if createdChat {
|
||
// send message to the client that the chat has been created
|
||
json.NewEncoder(w).Encode(responses.ChatEvent{
|
||
EventName: "chat_created",
|
||
ChatID: &cid,
|
||
})
|
||
flusher.Flush()
|
||
}
|
||
|
||
// Check if this is from a specific message index (e.g. for editing)
|
||
idx := -1
|
||
if req.Index != nil {
|
||
idx = *req.Index
|
||
}
|
||
|
||
// Load chat with attachments since we need them for processing
|
||
chat, err := s.Store.ChatWithOptions(cid, true)
|
||
if err != nil {
|
||
if !errors.Is(err, not.Found) {
|
||
return err
|
||
}
|
||
chat = store.NewChat(cid)
|
||
}
|
||
|
||
// Only add user message if not forceUpdate
|
||
if !req.ForceUpdate {
|
||
var messageOptions *store.MessageOptions
|
||
if len(req.Attachments) > 0 {
|
||
storeAttachments := make([]store.File, 0, len(req.Attachments))
|
||
|
||
for _, att := range req.Attachments {
|
||
if att.Data == "" {
|
||
// This is an existing file reference - keep it from the original message
|
||
if idx >= 0 && idx < len(chat.Messages) {
|
||
originalMessage := chat.Messages[idx]
|
||
// Find the file by filename in the original message
|
||
for _, originalFile := range originalMessage.Attachments {
|
||
if originalFile.Filename == att.Filename {
|
||
storeAttachments = append(storeAttachments, originalFile)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// This is a new file - decode base64 data
|
||
data, err := base64.StdEncoding.DecodeString(att.Data)
|
||
if err != nil {
|
||
s.log().Error("failed to decode attachment data", "error", err, "filename", att.Filename)
|
||
continue
|
||
}
|
||
|
||
storeAttachments = append(storeAttachments, store.File{
|
||
Filename: att.Filename,
|
||
Data: data,
|
||
})
|
||
}
|
||
}
|
||
|
||
messageOptions = &store.MessageOptions{
|
||
Attachments: storeAttachments,
|
||
}
|
||
}
|
||
userMsg := store.NewMessage("user", req.Prompt, messageOptions)
|
||
|
||
if idx >= 0 && idx < len(chat.Messages) {
|
||
// Generate from specified message: truncate and replace
|
||
chat.Messages = chat.Messages[:idx]
|
||
chat.Messages = append(chat.Messages, userMsg)
|
||
} else {
|
||
// Normal mode: append new message
|
||
chat.Messages = append(chat.Messages, userMsg)
|
||
}
|
||
|
||
if err := s.Store.SetChat(*chat); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
ctx, cancel := context.WithCancel(r.Context())
|
||
defer cancel()
|
||
|
||
_, cancelLoading := context.WithCancel(ctx)
|
||
loading := false
|
||
|
||
c, err := api.ClientFromEnvironment()
|
||
if err != nil {
|
||
cancelLoading()
|
||
return err
|
||
}
|
||
|
||
// Check if the model exists locally by trying to show it
|
||
// TODO (jmorganca): skip this round trip and instead just act
|
||
// on a 404 error on chat
|
||
_, err = c.Show(ctx, &api.ShowRequest{Model: req.Model})
|
||
if err != nil || req.ForceUpdate {
|
||
// Create an empty assistant message to store the model information
|
||
// This will be overwritten when the model responds
|
||
chat.Messages = append(chat.Messages, store.NewMessage("assistant", "", &store.MessageOptions{Model: req.Model}))
|
||
if err := s.Store.SetChat(*chat); err != nil {
|
||
cancelLoading()
|
||
return err
|
||
}
|
||
// Send download progress events while the model is being pulled
|
||
// TODO (jmorganca): this only shows the largest digest, but we
|
||
// should show the progress for the total size of the download
|
||
var largestDigest string
|
||
var largestTotal int64
|
||
|
||
err = c.Pull(ctx, &api.PullRequest{Model: req.Model}, func(progress api.ProgressResponse) error {
|
||
if progress.Digest != "" && progress.Total > largestTotal {
|
||
largestDigest = progress.Digest
|
||
largestTotal = progress.Total
|
||
}
|
||
|
||
if progress.Digest != "" && progress.Digest == largestDigest {
|
||
progressEvent := responses.DownloadEvent{
|
||
EventName: string(EventDownload),
|
||
Total: progress.Total,
|
||
Completed: progress.Completed,
|
||
Done: false,
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(progressEvent); err != nil {
|
||
return err
|
||
}
|
||
flusher.Flush()
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
s.log().Error("model download error", "error", err, "model", req.Model)
|
||
errorEvent := s.getError(err)
|
||
json.NewEncoder(w).Encode(errorEvent)
|
||
flusher.Flush()
|
||
cancelLoading()
|
||
return fmt.Errorf("failed to download model: %w", err)
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(responses.DownloadEvent{
|
||
EventName: string(EventDownload),
|
||
Completed: largestTotal,
|
||
Total: largestTotal,
|
||
Done: true,
|
||
}); err != nil {
|
||
cancelLoading()
|
||
return err
|
||
}
|
||
flusher.Flush()
|
||
|
||
// If forceUpdate, we're done after updating the model
|
||
if req.ForceUpdate {
|
||
json.NewEncoder(w).Encode(responses.ChatEvent{EventName: "done"})
|
||
flusher.Flush()
|
||
cancelLoading()
|
||
return nil
|
||
}
|
||
}
|
||
|
||
loading = true
|
||
defer cancelLoading()
|
||
|
||
// Check the model capabilities
|
||
details, err := c.Show(ctx, &api.ShowRequest{Model: req.Model})
|
||
|
||
if err != nil || details == nil {
|
||
errorEvent := s.getError(err)
|
||
json.NewEncoder(w).Encode(errorEvent)
|
||
flusher.Flush()
|
||
s.log().Error("failed to show model details", "error", err, "model", req.Model)
|
||
return nil
|
||
}
|
||
think := slices.Contains(details.Capabilities, model.CapabilityThinking)
|
||
|
||
var thinkValue any
|
||
|
||
if req.Think != nil {
|
||
thinkValue = req.Think
|
||
} else {
|
||
thinkValue = think
|
||
}
|
||
|
||
// Check if the last user message has attachments
|
||
// TODO (parthsareen): this logic will change with directory drag and drop
|
||
hasAttachments := false
|
||
if len(chat.Messages) > 0 {
|
||
lastMsg := chat.Messages[len(chat.Messages)-1]
|
||
if lastMsg.Role == "user" && len(lastMsg.Attachments) > 0 {
|
||
hasAttachments = true
|
||
}
|
||
}
|
||
|
||
// Check if agent or tools mode is enabled
|
||
// Note: Skip agent/tools mode if user has attachments, as the agent doesn't handle file attachments properly
|
||
registry := tools.NewRegistry()
|
||
var browser *tools.Browser
|
||
|
||
if !hasAttachments {
|
||
WebSearchEnabled := req.WebSearch != nil && *req.WebSearch
|
||
|
||
if WebSearchEnabled {
|
||
if supportsBrowserTools(req.Model) {
|
||
browserState, ok := s.browserState(chat)
|
||
if !ok {
|
||
browserState = reconstructBrowserState(chat.Messages, tools.DefaultViewTokens)
|
||
}
|
||
browser = tools.NewBrowser(browserState)
|
||
registry.Register(tools.NewBrowserSearch(browser))
|
||
registry.Register(tools.NewBrowserOpen(browser))
|
||
registry.Register(tools.NewBrowserFind(browser))
|
||
} else if supportsWebSearchTools(req.Model) {
|
||
registry.Register(&tools.WebSearch{})
|
||
registry.Register(&tools.WebFetch{})
|
||
}
|
||
}
|
||
}
|
||
|
||
var thinkingTimeStart *time.Time = nil
|
||
var thinkingTimeEnd *time.Time = nil
|
||
// Request-only assistant tool_calls buffer
|
||
// if tool_calls arrive before any assistant text, we keep them here,
|
||
// inject them into the next request, and attach on first assistant content/thinking.
|
||
var pendingAssistantToolCalls []store.ToolCall
|
||
|
||
passNum := 1
|
||
|
||
for {
|
||
var toolsExecuted bool
|
||
|
||
availableTools := registry.AvailableTools()
|
||
|
||
// If we have pending assistant tool_calls and no assistant yet,
|
||
// build the request against a temporary chat that includes a
|
||
// request-only assistant with tool_calls inserted BEFORE tool messages
|
||
reqChat := chat
|
||
if len(pendingAssistantToolCalls) > 0 {
|
||
if len(chat.Messages) == 0 || chat.Messages[len(chat.Messages)-1].Role != "assistant" {
|
||
temp := *chat
|
||
synth := store.NewMessage("assistant", "", &store.MessageOptions{Model: req.Model, ToolCalls: pendingAssistantToolCalls})
|
||
insertIdx := len(temp.Messages) - 1
|
||
for insertIdx >= 0 && temp.Messages[insertIdx].Role == "tool" {
|
||
insertIdx--
|
||
}
|
||
if insertIdx < 0 {
|
||
temp.Messages = append([]store.Message{synth}, temp.Messages...)
|
||
} else {
|
||
tmp := make([]store.Message, 0, len(temp.Messages)+1)
|
||
tmp = append(tmp, temp.Messages[:insertIdx+1]...)
|
||
tmp = append(tmp, synth)
|
||
tmp = append(tmp, temp.Messages[insertIdx+1:]...)
|
||
temp.Messages = tmp
|
||
}
|
||
|
||
reqChat = &temp
|
||
}
|
||
}
|
||
chatReq, err := s.buildChatRequest(reqChat, req.Model, thinkValue, availableTools)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
err = c.Chat(ctx, chatReq, func(res api.ChatResponse) error {
|
||
if loading {
|
||
// Remove the loading indicator on first token
|
||
cancelLoading()
|
||
loading = false
|
||
}
|
||
|
||
// Start thinking timer on first thinking content or after tool call when thinking again
|
||
if res.Message.Thinking != "" && (thinkingTimeStart == nil || thinkingTimeEnd != nil) {
|
||
now := time.Now()
|
||
thinkingTimeStart = &now
|
||
thinkingTimeEnd = nil
|
||
}
|
||
|
||
if res.Message.Content == "" && res.Message.Thinking == "" && len(res.Message.ToolCalls) == 0 {
|
||
return nil
|
||
}
|
||
|
||
event := EventChat
|
||
if thinkingTimeStart != nil && res.Message.Content == "" && len(res.Message.ToolCalls) == 0 {
|
||
event = EventThinking
|
||
}
|
||
|
||
if len(res.Message.ToolCalls) > 0 {
|
||
event = EventToolCall
|
||
}
|
||
|
||
if event == EventToolCall && thinkingTimeStart != nil && thinkingTimeEnd == nil {
|
||
now := time.Now()
|
||
thinkingTimeEnd = &now
|
||
}
|
||
|
||
if event == EventChat && thinkingTimeStart != nil && thinkingTimeEnd == nil && res.Message.Content != "" {
|
||
now := time.Now()
|
||
thinkingTimeEnd = &now
|
||
}
|
||
|
||
json.NewEncoder(w).Encode(chatEventFromApiChatResponse(res, thinkingTimeStart, thinkingTimeEnd))
|
||
flusher.Flush()
|
||
|
||
switch event {
|
||
case EventToolCall:
|
||
if thinkingTimeEnd != nil {
|
||
if len(chat.Messages) > 0 && chat.Messages[len(chat.Messages)-1].Role == "assistant" {
|
||
lastMsg := &chat.Messages[len(chat.Messages)-1]
|
||
lastMsg.ThinkingTimeEnd = thinkingTimeEnd
|
||
lastMsg.UpdatedAt = time.Now()
|
||
s.Store.UpdateLastMessage(chat.ID, *lastMsg)
|
||
}
|
||
thinkingTimeStart = nil
|
||
thinkingTimeEnd = nil
|
||
}
|
||
|
||
// attach tool_calls to an existing assistant if present,
|
||
// otherwise (for standalone web_search/web_fetch) buffer for request-only injection.
|
||
if len(res.Message.ToolCalls) > 0 {
|
||
if len(chat.Messages) > 0 && chat.Messages[len(chat.Messages)-1].Role == "assistant" {
|
||
toolCalls := make([]store.ToolCall, len(res.Message.ToolCalls))
|
||
for i, tc := range res.Message.ToolCalls {
|
||
argsJSON, _ := json.Marshal(tc.Function.Arguments)
|
||
toolCalls[i] = store.ToolCall{
|
||
Type: "function",
|
||
Function: store.ToolFunction{
|
||
Name: tc.Function.Name,
|
||
Arguments: string(argsJSON),
|
||
},
|
||
}
|
||
}
|
||
lastMsg := &chat.Messages[len(chat.Messages)-1]
|
||
lastMsg.ToolCalls = toolCalls
|
||
if err := s.Store.UpdateLastMessage(chat.ID, *lastMsg); err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
onlyStandalone := true
|
||
for _, tc := range res.Message.ToolCalls {
|
||
if !(tc.Function.Name == "web_search" || tc.Function.Name == "web_fetch") {
|
||
onlyStandalone = false
|
||
break
|
||
}
|
||
}
|
||
if onlyStandalone {
|
||
toolCalls := make([]store.ToolCall, len(res.Message.ToolCalls))
|
||
for i, tc := range res.Message.ToolCalls {
|
||
argsJSON, _ := json.Marshal(tc.Function.Arguments)
|
||
toolCalls[i] = store.ToolCall{
|
||
Type: "function",
|
||
Function: store.ToolFunction{
|
||
Name: tc.Function.Name,
|
||
Arguments: string(argsJSON),
|
||
},
|
||
}
|
||
}
|
||
|
||
synth := store.NewMessage("assistant", "", &store.MessageOptions{Model: req.Model, ToolCalls: toolCalls})
|
||
chat.Messages = append(chat.Messages, synth)
|
||
if err := s.Store.AppendMessage(chat.ID, synth); err != nil {
|
||
return err
|
||
}
|
||
|
||
// clear buffer to avoid-injecting again
|
||
pendingAssistantToolCalls = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, toolCall := range res.Message.ToolCalls {
|
||
// continues loop as tools were executed
|
||
toolsExecuted = true
|
||
result, content, err := registry.Execute(ctx, toolCall.Function.Name, toolCall.Function.Arguments)
|
||
if err != nil {
|
||
errContent := fmt.Sprintf("Error: %v", err)
|
||
toolErrMsg := store.NewMessage("tool", errContent, nil)
|
||
toolErrMsg.ToolName = toolCall.Function.Name
|
||
chat.Messages = append(chat.Messages, toolErrMsg)
|
||
if err := s.Store.AppendMessage(chat.ID, toolErrMsg); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Emit tool error event
|
||
toolResult := true
|
||
json.NewEncoder(w).Encode(responses.ChatEvent{
|
||
EventName: "tool",
|
||
Content: &errContent,
|
||
ToolName: &toolCall.Function.Name,
|
||
})
|
||
flusher.Flush()
|
||
|
||
json.NewEncoder(w).Encode(responses.ChatEvent{
|
||
EventName: "tool_result",
|
||
Content: &errContent,
|
||
ToolName: &toolCall.Function.Name,
|
||
ToolResult: &toolResult,
|
||
ToolResultData: nil, // No result data for errors
|
||
})
|
||
flusher.Flush()
|
||
continue
|
||
}
|
||
|
||
var tr json.RawMessage
|
||
if strings.HasPrefix(toolCall.Function.Name, "browser.search") {
|
||
// For standalone web_search, ensure the tool message has readable content
|
||
// so the second-pass model can consume results, while keeping browser state flow intact.
|
||
// We still persist tool msg with content below.
|
||
// (No browser state update needed for standalone.)
|
||
} else if strings.HasPrefix(toolCall.Function.Name, "browser") {
|
||
stateBytes, err := json.Marshal(browser.State())
|
||
if err != nil {
|
||
return fmt.Errorf("failed to marshal browser state: %w", err)
|
||
}
|
||
if err := s.Store.UpdateChatBrowserState(chat.ID, json.RawMessage(stateBytes)); err != nil {
|
||
return fmt.Errorf("failed to persist browser state to chat: %w", err)
|
||
}
|
||
// tool result is not added to the tool message for the browser tool
|
||
} else {
|
||
var err error
|
||
tr, err = json.Marshal(result)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to marshal tool result: %w", err)
|
||
}
|
||
}
|
||
// ensure tool message sent back to the model has content (if empty, use a sensible fallback)
|
||
modelContent := content
|
||
if toolCall.Function.Name == "web_fetch" && modelContent == "" {
|
||
if str, ok := result.(string); ok {
|
||
modelContent = str
|
||
}
|
||
}
|
||
if modelContent == "" && len(tr) > 0 {
|
||
s.log().Debug("tool message empty, sending json result")
|
||
modelContent = string(tr)
|
||
}
|
||
toolMsg := store.NewMessage("tool", modelContent, &store.MessageOptions{
|
||
ToolResult: &tr,
|
||
})
|
||
toolMsg.ToolName = toolCall.Function.Name
|
||
chat.Messages = append(chat.Messages, toolMsg)
|
||
|
||
s.Store.AppendMessage(chat.ID, toolMsg)
|
||
|
||
// Emit tool message event (matching agent pattern)
|
||
toolResult := true
|
||
json.NewEncoder(w).Encode(responses.ChatEvent{
|
||
EventName: "tool",
|
||
Content: &content,
|
||
ToolName: &toolCall.Function.Name,
|
||
})
|
||
flusher.Flush()
|
||
|
||
var toolState any = nil
|
||
if browser != nil {
|
||
toolState = browser.State()
|
||
}
|
||
// Stream tool result to frontend
|
||
|
||
json.NewEncoder(w).Encode(responses.ChatEvent{
|
||
EventName: "tool_result",
|
||
Content: &content,
|
||
ToolName: &toolCall.Function.Name,
|
||
ToolResult: &toolResult,
|
||
ToolResultData: result,
|
||
ToolState: toolState,
|
||
})
|
||
flusher.Flush()
|
||
}
|
||
|
||
case EventChat:
|
||
// Append the new message to the chat history
|
||
if len(chat.Messages) == 0 || chat.Messages[len(chat.Messages)-1].Role != "assistant" {
|
||
newMsg := store.NewMessage("assistant", "", &store.MessageOptions{Model: req.Model})
|
||
chat.Messages = append(chat.Messages, newMsg)
|
||
// Append new message to database
|
||
if err := s.Store.AppendMessage(chat.ID, newMsg); err != nil {
|
||
return err
|
||
}
|
||
// Attach any buffered tool_calls (request-only) now that assistant has started
|
||
if len(pendingAssistantToolCalls) > 0 {
|
||
lastMsg := &chat.Messages[len(chat.Messages)-1]
|
||
lastMsg.ToolCalls = pendingAssistantToolCalls
|
||
|
||
pendingAssistantToolCalls = nil
|
||
if err := s.Store.UpdateLastMessage(chat.ID, *lastMsg); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
|
||
// Append token to last assistant message & persist
|
||
lastMsg := &chat.Messages[len(chat.Messages)-1]
|
||
lastMsg.Content += res.Message.Content
|
||
lastMsg.UpdatedAt = time.Now()
|
||
// Update thinking time fields
|
||
if thinkingTimeStart != nil {
|
||
lastMsg.ThinkingTimeStart = thinkingTimeStart
|
||
}
|
||
if thinkingTimeEnd != nil {
|
||
lastMsg.ThinkingTimeEnd = thinkingTimeEnd
|
||
}
|
||
// Use optimized update for streaming
|
||
if err := s.Store.UpdateLastMessage(chat.ID, *lastMsg); err != nil {
|
||
return err
|
||
}
|
||
case EventThinking:
|
||
// Persist thinking content
|
||
if len(chat.Messages) == 0 || chat.Messages[len(chat.Messages)-1].Role != "assistant" {
|
||
newMsg := store.NewMessage("assistant", "", &store.MessageOptions{
|
||
Model: req.Model,
|
||
Thinking: res.Message.Thinking,
|
||
})
|
||
chat.Messages = append(chat.Messages, newMsg)
|
||
// Append new message to database
|
||
if err := s.Store.AppendMessage(chat.ID, newMsg); err != nil {
|
||
return err
|
||
}
|
||
// Attach any buffered tool_calls now that assistant exists
|
||
if len(pendingAssistantToolCalls) > 0 {
|
||
lastMsg := &chat.Messages[len(chat.Messages)-1]
|
||
lastMsg.ToolCalls = pendingAssistantToolCalls
|
||
|
||
pendingAssistantToolCalls = nil
|
||
if err := s.Store.UpdateLastMessage(chat.ID, *lastMsg); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
} else {
|
||
// Update thinking content of existing message
|
||
lastMsg := &chat.Messages[len(chat.Messages)-1]
|
||
lastMsg.Thinking += res.Message.Thinking
|
||
lastMsg.UpdatedAt = time.Now()
|
||
// Update thinking time fields
|
||
if thinkingTimeStart != nil {
|
||
lastMsg.ThinkingTimeStart = thinkingTimeStart
|
||
}
|
||
if thinkingTimeEnd != nil {
|
||
lastMsg.ThinkingTimeEnd = thinkingTimeEnd
|
||
}
|
||
|
||
// Use optimized update for streaming
|
||
if err := s.Store.UpdateLastMessage(chat.ID, *lastMsg); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
s.log().Error("chat stream error", "error", err)
|
||
errorEvent := s.getError(err)
|
||
json.NewEncoder(w).Encode(errorEvent)
|
||
flusher.Flush()
|
||
return nil
|
||
}
|
||
|
||
// If no tools were executed, exit the loop
|
||
if !toolsExecuted {
|
||
break
|
||
}
|
||
|
||
passNum++
|
||
}
|
||
|
||
// handle cases where thinking started but didn't finish
|
||
// this can happen if the client disconnects or the request is cancelled
|
||
// TODO (jmorganca): this should be merged with code above
|
||
if thinkingTimeStart != nil && thinkingTimeEnd == nil {
|
||
now := time.Now()
|
||
thinkingTimeEnd = &now
|
||
if len(chat.Messages) > 0 && chat.Messages[len(chat.Messages)-1].Role == "assistant" {
|
||
lastMsg := &chat.Messages[len(chat.Messages)-1]
|
||
lastMsg.ThinkingTimeEnd = thinkingTimeEnd
|
||
lastMsg.UpdatedAt = time.Now()
|
||
s.Store.UpdateLastMessage(chat.ID, *lastMsg)
|
||
}
|
||
}
|
||
|
||
json.NewEncoder(w).Encode(responses.ChatEvent{EventName: "done"})
|
||
flusher.Flush()
|
||
|
||
if len(chat.Messages) > 0 {
|
||
chat.Messages[len(chat.Messages)-1].Stream = false
|
||
}
|
||
return s.Store.SetChat(*chat)
|
||
}
|
||
|
||
func (s *Server) getChat(w http.ResponseWriter, r *http.Request) error {
|
||
cid := r.PathValue("id")
|
||
|
||
if cid == "" {
|
||
return fmt.Errorf("chat ID is required")
|
||
}
|
||
|
||
chat, err := s.Store.Chat(cid)
|
||
if err != nil {
|
||
// Return empty chat if not found
|
||
data := responses.ChatResponse{
|
||
Chat: store.Chat{},
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(data)
|
||
return nil //nolint:nilerr
|
||
}
|
||
|
||
// fill missing tool_name on tool messages (from previous tool_calls) so labels don’t flip after reload.
|
||
if chat != nil && len(chat.Messages) > 0 {
|
||
for i := range chat.Messages {
|
||
if chat.Messages[i].Role == "tool" && chat.Messages[i].ToolName == "" && chat.Messages[i].ToolResult != nil {
|
||
for j := i - 1; j >= 0; j-- {
|
||
if chat.Messages[j].Role == "assistant" && len(chat.Messages[j].ToolCalls) > 0 {
|
||
last := chat.Messages[j].ToolCalls[len(chat.Messages[j].ToolCalls)-1]
|
||
if last.Function.Name != "" {
|
||
chat.Messages[i].ToolName = last.Function.Name
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
browserState, ok := s.browserState(chat)
|
||
if !ok {
|
||
browserState = reconstructBrowserState(chat.Messages, tools.DefaultViewTokens)
|
||
}
|
||
// clear the text and lines of all pages as it is not needed for rendering
|
||
if browserState != nil {
|
||
for _, page := range browserState.URLToPage {
|
||
page.Lines = nil
|
||
page.Text = ""
|
||
}
|
||
|
||
if cleanedState, err := json.Marshal(browserState); err == nil {
|
||
chat.BrowserState = json.RawMessage(cleanedState)
|
||
}
|
||
}
|
||
data := responses.ChatResponse{
|
||
Chat: *chat,
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(data)
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) renameChat(w http.ResponseWriter, r *http.Request) error {
|
||
cid := r.PathValue("id")
|
||
if cid == "" {
|
||
return fmt.Errorf("chat ID is required")
|
||
}
|
||
|
||
var req struct {
|
||
Title string `json:"title"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
return fmt.Errorf("invalid request body: %w", err)
|
||
}
|
||
|
||
// Get the chat without loading attachments (we only need to update the title)
|
||
chat, err := s.Store.ChatWithOptions(cid, false)
|
||
if err != nil {
|
||
return fmt.Errorf("chat not found: %w", err)
|
||
}
|
||
|
||
// Update the title
|
||
chat.Title = req.Title
|
||
if err := s.Store.SetChat(*chat); err != nil {
|
||
return fmt.Errorf("failed to update chat: %w", err)
|
||
}
|
||
|
||
// Return the updated chat info
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(chatInfoFromChat(*chat))
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) deleteChat(w http.ResponseWriter, r *http.Request) error {
|
||
cid := r.PathValue("id")
|
||
if cid == "" {
|
||
return fmt.Errorf("chat ID is required")
|
||
}
|
||
|
||
// Check if the chat exists (no need to load attachments)
|
||
_, err := s.Store.ChatWithOptions(cid, false)
|
||
if err != nil {
|
||
if errors.Is(err, not.Found) {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return fmt.Errorf("chat not found")
|
||
}
|
||
return fmt.Errorf("failed to get chat: %w", err)
|
||
}
|
||
|
||
// Delete the chat
|
||
if err := s.Store.DeleteChat(cid); err != nil {
|
||
return fmt.Errorf("failed to delete chat: %w", err)
|
||
}
|
||
|
||
w.WriteHeader(http.StatusOK)
|
||
return nil
|
||
}
|
||
|
||
// TODO(parthsareen): consolidate events within the function
|
||
func chatEventFromApiChatResponse(res api.ChatResponse, thinkingTimeStart *time.Time, thinkingTimeEnd *time.Time) responses.ChatEvent {
|
||
// If there are tool calls, send assistant_with_tools event
|
||
if len(res.Message.ToolCalls) > 0 {
|
||
// Convert API tool calls to store tool calls
|
||
storeToolCalls := make([]store.ToolCall, len(res.Message.ToolCalls))
|
||
for i, tc := range res.Message.ToolCalls {
|
||
argsJSON, _ := json.Marshal(tc.Function.Arguments)
|
||
storeToolCalls[i] = store.ToolCall{
|
||
Type: "function",
|
||
Function: store.ToolFunction{
|
||
Name: tc.Function.Name,
|
||
Arguments: string(argsJSON),
|
||
},
|
||
}
|
||
}
|
||
|
||
var content *string
|
||
if res.Message.Content != "" {
|
||
content = &res.Message.Content
|
||
}
|
||
var thinking *string
|
||
if res.Message.Thinking != "" {
|
||
thinking = &res.Message.Thinking
|
||
}
|
||
|
||
return responses.ChatEvent{
|
||
EventName: "assistant_with_tools",
|
||
Content: content,
|
||
Thinking: thinking,
|
||
ToolCalls: storeToolCalls,
|
||
ThinkingTimeStart: thinkingTimeStart,
|
||
ThinkingTimeEnd: thinkingTimeEnd,
|
||
}
|
||
}
|
||
|
||
// Otherwise, send regular chat event
|
||
var content *string
|
||
if res.Message.Content != "" {
|
||
content = &res.Message.Content
|
||
}
|
||
var thinking *string
|
||
if res.Message.Thinking != "" {
|
||
thinking = &res.Message.Thinking
|
||
}
|
||
|
||
return responses.ChatEvent{
|
||
EventName: "chat",
|
||
Content: content,
|
||
Thinking: thinking,
|
||
ThinkingTimeStart: thinkingTimeStart,
|
||
ThinkingTimeEnd: thinkingTimeEnd,
|
||
}
|
||
}
|
||
|
||
func chatInfoFromChat(chat store.Chat) responses.ChatInfo {
|
||
userExcerpt := ""
|
||
var updatedAt time.Time
|
||
|
||
for _, msg := range chat.Messages {
|
||
// extract the first user message as the user excerpt
|
||
if msg.Role == "user" && userExcerpt == "" {
|
||
userExcerpt = msg.Content
|
||
}
|
||
// update the updated at time
|
||
if msg.UpdatedAt.After(updatedAt) {
|
||
updatedAt = msg.UpdatedAt
|
||
}
|
||
}
|
||
|
||
return responses.ChatInfo{
|
||
ID: chat.ID,
|
||
Title: chat.Title,
|
||
UserExcerpt: userExcerpt,
|
||
CreatedAt: chat.CreatedAt,
|
||
UpdatedAt: updatedAt,
|
||
}
|
||
}
|
||
|
||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) error {
|
||
settings, err := s.Store.Settings()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to load settings: %w", err)
|
||
}
|
||
|
||
// set default models directory if not set
|
||
if settings.Models == "" {
|
||
settings.Models = envconfig.Models()
|
||
}
|
||
|
||
// set default context length if not set
|
||
if settings.ContextLength == 0 {
|
||
settings.ContextLength = 4096
|
||
}
|
||
|
||
// Include current runtime settings
|
||
settings.Agent = s.Agent
|
||
settings.Tools = s.Tools
|
||
settings.WorkingDir = s.WorkingDir
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
return json.NewEncoder(w).Encode(responses.SettingsResponse{
|
||
Settings: settings,
|
||
})
|
||
}
|
||
|
||
func (s *Server) settings(w http.ResponseWriter, r *http.Request) error {
|
||
old, err := s.Store.Settings()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to load settings: %w", err)
|
||
}
|
||
|
||
var settings store.Settings
|
||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||
return fmt.Errorf("invalid request body: %w", err)
|
||
}
|
||
|
||
if err := s.Store.SetSettings(settings); err != nil {
|
||
return fmt.Errorf("failed to save settings: %w", err)
|
||
}
|
||
|
||
if old.ContextLength != settings.ContextLength ||
|
||
old.Models != settings.Models ||
|
||
old.Expose != settings.Expose {
|
||
s.Restart()
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
return json.NewEncoder(w).Encode(responses.SettingsResponse{
|
||
Settings: settings,
|
||
})
|
||
}
|
||
|
||
func (s *Server) me(w http.ResponseWriter, r *http.Request) error {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||
return nil
|
||
}
|
||
|
||
user, err := s.UserData(r.Context())
|
||
if err != nil {
|
||
// If fetching from API fails, try to return cached user data if available
|
||
if cachedUser, cacheErr := s.Store.User(); cacheErr == nil && cachedUser != nil {
|
||
s.log().Info("API request failed, returning cached user data", "error", err)
|
||
responseUser := &responses.User{
|
||
Name: cachedUser.Name,
|
||
Email: cachedUser.Email,
|
||
Plan: cachedUser.Plan,
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
return json.NewEncoder(w).Encode(responseUser)
|
||
}
|
||
|
||
s.log().Error("failed to get user data", "error", err)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
return json.NewEncoder(w).Encode(responses.Error{
|
||
Error: "failed to get user data",
|
||
})
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
return json.NewEncoder(w).Encode(user)
|
||
}
|
||
|
||
func (s *Server) disconnect(w http.ResponseWriter, r *http.Request) error {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||
return nil
|
||
}
|
||
|
||
if err := s.Store.ClearUser(); err != nil {
|
||
s.log().Warn("failed to clear cached user data", "error", err)
|
||
}
|
||
|
||
// Get the SSH public key to encode for the delete request
|
||
pubKey, err := ollamaAuth.GetPublicKey()
|
||
if err != nil {
|
||
s.log().Error("failed to get public key", "error", err)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
return json.NewEncoder(w).Encode(responses.Error{
|
||
Error: "failed to get public key",
|
||
})
|
||
}
|
||
|
||
// Encode the key using base64 URL encoding
|
||
encodedKey := base64.RawURLEncoding.EncodeToString([]byte(pubKey))
|
||
|
||
// Call the /api/user/keys/{encodedKey} endpoint with DELETE
|
||
resp, err := s.doSelfSigned(r.Context(), http.MethodDelete, fmt.Sprintf("/api/user/keys/%s", encodedKey))
|
||
if err != nil {
|
||
s.log().Error("failed to call ollama.com/api/user/keys", "error", err)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
return json.NewEncoder(w).Encode(responses.Error{
|
||
Error: "failed to disconnect from ollama.com",
|
||
})
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
s.log().Error("disconnect request failed", "status", resp.StatusCode)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
return json.NewEncoder(w).Encode(responses.Error{
|
||
Error: "failed to disconnect from ollama.com",
|
||
})
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
return json.NewEncoder(w).Encode(map[string]string{"status": "disconnected"})
|
||
}
|
||
|
||
func (s *Server) connectURL(w http.ResponseWriter, r *http.Request) error {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||
return nil
|
||
}
|
||
|
||
connectURL, err := auth.BuildConnectURL(OllamaDotCom)
|
||
if err != nil {
|
||
s.log().Error("failed to build connect URL", "error", err)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
return json.NewEncoder(w).Encode(responses.Error{
|
||
Error: "failed to build connect URL",
|
||
})
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
return json.NewEncoder(w).Encode(map[string]string{
|
||
"connect_url": connectURL,
|
||
})
|
||
}
|
||
|
||
func (s *Server) health(w http.ResponseWriter, r *http.Request) error {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||
return nil
|
||
}
|
||
|
||
healthy := false
|
||
c, err := api.ClientFromEnvironment()
|
||
if err == nil {
|
||
if _, err := c.Version(r.Context()); err == nil {
|
||
healthy = true
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
return json.NewEncoder(w).Encode(responses.HealthResponse{
|
||
Healthy: healthy,
|
||
})
|
||
}
|
||
|
||
func (s *Server) getInferenceCompute(w http.ResponseWriter, r *http.Request) error {
|
||
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
|
||
defer cancel()
|
||
serverInferenceComputes, err := server.GetInferenceComputer(ctx)
|
||
if err != nil {
|
||
s.log().Error("failed to get inference compute", "error", err)
|
||
return fmt.Errorf("failed to get inference compute: %w", err)
|
||
}
|
||
|
||
inferenceComputes := make([]responses.InferenceCompute, len(serverInferenceComputes))
|
||
for i, ic := range serverInferenceComputes {
|
||
inferenceComputes[i] = responses.InferenceCompute{
|
||
Library: ic.Library,
|
||
Variant: ic.Variant,
|
||
Compute: ic.Compute,
|
||
Driver: ic.Driver,
|
||
Name: ic.Name,
|
||
VRAM: ic.VRAM,
|
||
}
|
||
}
|
||
|
||
response := responses.InferenceComputeResponse{
|
||
InferenceComputes: inferenceComputes,
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
return json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
func (s *Server) modelUpstream(w http.ResponseWriter, r *http.Request) error {
|
||
if r.Method != "POST" {
|
||
return fmt.Errorf("method not allowed")
|
||
}
|
||
|
||
var req struct {
|
||
Model string `json:"model"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
return fmt.Errorf("invalid request body: %w", err)
|
||
}
|
||
|
||
if req.Model == "" {
|
||
return fmt.Errorf("model is required")
|
||
}
|
||
|
||
digest, pushTime, err := s.checkModelUpstream(r.Context(), req.Model, 5*time.Second)
|
||
if err != nil {
|
||
s.log().Warn("failed to check upstream digest", "error", err, "model", req.Model)
|
||
response := responses.ModelUpstreamResponse{
|
||
Error: err.Error(),
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
return json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
response := responses.ModelUpstreamResponse{
|
||
Digest: digest,
|
||
PushTime: pushTime,
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
return json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
func userAgent() string {
|
||
buildinfo, _ := debug.ReadBuildInfo()
|
||
|
||
version := buildinfo.Main.Version
|
||
if version == "(devel)" {
|
||
// When using `go run .` the version is "(devel)". This is seen
|
||
// as an invalid version by ollama.com and so it defaults to
|
||
// "needs upgrade" for some requests, such as pulls. These
|
||
// checks can be skipped by using the special version "v0.0.0",
|
||
// so we set it to that here.
|
||
version = "v0.0.0"
|
||
}
|
||
|
||
return fmt.Sprintf("ollama/%s (%s %s) app/%s Go/%s",
|
||
version,
|
||
runtime.GOARCH,
|
||
runtime.GOOS,
|
||
version,
|
||
runtime.Version(),
|
||
)
|
||
}
|
||
|
||
// convertToOllamaTool converts a tool schema from our tools package format to Ollama API format
|
||
func convertToOllamaTool(toolSchema map[string]any) api.Tool {
|
||
tool := api.Tool{
|
||
Type: "function",
|
||
Function: api.ToolFunction{
|
||
Name: getStringFromMap(toolSchema, "name", ""),
|
||
Description: getStringFromMap(toolSchema, "description", ""),
|
||
},
|
||
}
|
||
|
||
tool.Function.Parameters.Type = "object"
|
||
tool.Function.Parameters.Required = []string{}
|
||
tool.Function.Parameters.Properties = make(map[string]api.ToolProperty)
|
||
|
||
if schemaProps, ok := toolSchema["schema"].(map[string]any); ok {
|
||
tool.Function.Parameters.Type = getStringFromMap(schemaProps, "type", "object")
|
||
|
||
if props, ok := schemaProps["properties"].(map[string]any); ok {
|
||
tool.Function.Parameters.Properties = make(map[string]api.ToolProperty)
|
||
|
||
for propName, propDef := range props {
|
||
if propMap, ok := propDef.(map[string]any); ok {
|
||
prop := api.ToolProperty{
|
||
Type: api.PropertyType{getStringFromMap(propMap, "type", "string")},
|
||
Description: getStringFromMap(propMap, "description", ""),
|
||
}
|
||
tool.Function.Parameters.Properties[propName] = prop
|
||
}
|
||
}
|
||
}
|
||
|
||
if required, ok := schemaProps["required"].([]string); ok {
|
||
tool.Function.Parameters.Required = required
|
||
} else if requiredAny, ok := schemaProps["required"].([]any); ok {
|
||
required := make([]string, len(requiredAny))
|
||
for i, r := range requiredAny {
|
||
if s, ok := r.(string); ok {
|
||
required[i] = s
|
||
}
|
||
}
|
||
tool.Function.Parameters.Required = required
|
||
}
|
||
}
|
||
|
||
return tool
|
||
}
|
||
|
||
// getStringFromMap safely gets a string from a map
|
||
func getStringFromMap(m map[string]any, key, defaultValue string) string {
|
||
if val, ok := m[key].(string); ok {
|
||
return val
|
||
}
|
||
return defaultValue
|
||
}
|
||
|
||
// isImageAttachment checks if a filename is an image file
|
||
func isImageAttachment(filename string) bool {
|
||
ext := strings.ToLower(filename)
|
||
return strings.HasSuffix(ext, ".png") || strings.HasSuffix(ext, ".jpg") || strings.HasSuffix(ext, ".jpeg")
|
||
}
|
||
|
||
// ptr is a convenience function for &literal
|
||
func ptr[T any](v T) *T { return &v }
|
||
|
||
// Browser tools simulate a full browser environment, allowing for actions like searching, opening, and interacting with web pages (e.g., "browser_search", "browser_open", "browser_find"). Currently only gpt-oss models support browser tools.
|
||
func supportsBrowserTools(model string) bool {
|
||
return strings.HasPrefix(strings.ToLower(model), "gpt-oss")
|
||
}
|
||
|
||
// Web search tools are simpler, providing only basic web search and fetch capabilities (e.g., "web_search", "web_fetch") without simulating a browser. Currently only qwen3 and deepseek-v3 support web search tools.
|
||
func supportsWebSearchTools(model string) bool {
|
||
model = strings.ToLower(model)
|
||
prefixes := []string{"qwen3", "deepseek-v3"}
|
||
for _, p := range prefixes {
|
||
if strings.HasPrefix(model, p) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// buildChatRequest converts store.Chat to api.ChatRequest
|
||
func (s *Server) buildChatRequest(chat *store.Chat, model string, think any, availableTools []map[string]any) (*api.ChatRequest, error) {
|
||
var msgs []api.Message
|
||
for _, m := range chat.Messages {
|
||
// Skip empty messages if present
|
||
if m.Content == "" && m.Thinking == "" && len(m.ToolCalls) == 0 && len(m.Attachments) == 0 {
|
||
continue
|
||
}
|
||
|
||
apiMsg := api.Message{Role: m.Role, Thinking: m.Thinking}
|
||
|
||
sb := strings.Builder{}
|
||
sb.WriteString(m.Content)
|
||
|
||
var images []api.ImageData
|
||
if m.Role == "user" && len(m.Attachments) > 0 {
|
||
for _, a := range m.Attachments {
|
||
if isImageAttachment(a.Filename) {
|
||
images = append(images, api.ImageData(a.Data))
|
||
} else {
|
||
content := convertBytesToText(a.Data, a.Filename)
|
||
sb.WriteString(fmt.Sprintf("\n--- File: %s ---\n%s\n--- End of %s ---",
|
||
a.Filename, content, a.Filename))
|
||
}
|
||
}
|
||
}
|
||
|
||
apiMsg.Content = sb.String()
|
||
apiMsg.Images = images
|
||
|
||
switch m.Role {
|
||
case "assistant":
|
||
if len(m.ToolCalls) > 0 {
|
||
var toolCalls []api.ToolCall
|
||
for _, tc := range m.ToolCalls {
|
||
var args api.ToolCallFunctionArguments
|
||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||
s.log().Error("failed to parse tool call arguments", "error", err, "function_name", tc.Function.Name, "arguments", tc.Function.Arguments)
|
||
continue
|
||
}
|
||
|
||
toolCalls = append(toolCalls, api.ToolCall{
|
||
Function: api.ToolCallFunction{
|
||
Name: tc.Function.Name,
|
||
Arguments: args,
|
||
},
|
||
})
|
||
}
|
||
apiMsg.ToolCalls = toolCalls
|
||
}
|
||
case "tool":
|
||
apiMsg.Role = "tool"
|
||
apiMsg.Content = m.Content
|
||
apiMsg.ToolName = m.ToolName
|
||
case "user", "system":
|
||
// User and system messages are handled normally
|
||
default:
|
||
// Log unknown roles but still include them
|
||
s.log().Debug("unknown message role", "role", m.Role)
|
||
}
|
||
|
||
msgs = append(msgs, apiMsg)
|
||
}
|
||
|
||
var thinkValue *api.ThinkValue
|
||
if think != nil {
|
||
if boolValue, ok := think.(bool); ok {
|
||
thinkValue = &api.ThinkValue{
|
||
Value: boolValue,
|
||
}
|
||
} else if stringValue, ok := think.(string); ok {
|
||
thinkValue = &api.ThinkValue{
|
||
Value: stringValue,
|
||
}
|
||
}
|
||
}
|
||
|
||
req := &api.ChatRequest{
|
||
Model: model,
|
||
Messages: msgs,
|
||
Stream: ptr(true),
|
||
Think: thinkValue,
|
||
}
|
||
|
||
if len(availableTools) > 0 {
|
||
tools := make(api.Tools, len(availableTools))
|
||
for i, toolSchema := range availableTools {
|
||
tools[i] = convertToOllamaTool(toolSchema)
|
||
}
|
||
req.Tools = tools
|
||
}
|
||
|
||
return req, nil
|
||
}
|