+ Enter to send · Shift+Enter for new line +
+diff --git a/.golangci.yml b/.golangci.yml index 03293eb..b2bc8d3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,16 +5,16 @@ run: linters: disable-all: true enable: - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - unused - - misspell - - gofmt + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - misspell + - gofmt issues: exclude-use-default: false max-issues-per-linter: 0 - max-same-issues: 0 \ No newline at end of file + max-same-issues: 0 diff --git a/cmd/examples/httpserver/main.go b/cmd/examples/httpserver/main.go index cada521..6a2e467 100644 --- a/cmd/examples/httpserver/main.go +++ b/cmd/examples/httpserver/main.go @@ -2,12 +2,15 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "os" "os/signal" + "sync" "syscall" "time" @@ -19,6 +22,8 @@ import ( "github.com/hungpdn/llmgo/pkg/orchestrator" "github.com/hungpdn/llmgo/pkg/tools" "github.com/hungpdn/llmgo/pkg/tools/std" + "github.com/openai/openai-go" + "github.com/openai/openai-go/option" ) // AppContext holds shared dependencies (Dependency Injection). @@ -39,6 +44,73 @@ type ChatResponse struct { Error string `json:"error,omitempty"` } +// ─── Web-chat session store ─────────────────────────────────────────────────── +// Kept separate from AppContext so the original multi-agent flow is untouched. + +type webSession struct { + apiKey string + createdAt time.Time +} + +type sessionStore struct { + mu sync.RWMutex + data map[string]*webSession +} + +var sessions = &sessionStore{data: make(map[string]*webSession)} + +func init() { + // Background cleanup: remove sessions older than 24 h + go func() { + for range time.NewTicker(10 * time.Minute).C { + sessions.mu.Lock() + for id, s := range sessions.data { + if time.Since(s.createdAt) > 24*time.Hour { + delete(sessions.data, id) + } + } + sessions.mu.Unlock() + } + }() +} + +func (s *sessionStore) create(apiKey string) string { + b := make([]byte, 16) + _, _ = rand.Read(b) + id := hex.EncodeToString(b) + s.mu.Lock() + s.data[id] = &webSession{apiKey: apiKey, createdAt: time.Now()} + s.mu.Unlock() + return id +} + +func (s *sessionStore) get(id string) (string, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + if sess, ok := s.data[id]; ok { + return sess.apiKey, true + } + return "", false +} + +func (s *sessionStore) delete(id string) { + s.mu.Lock() + delete(s.data, id) + s.mu.Unlock() +} + +// ─── Web-chat message types ─────────────────────────────────────────────────── + +type webChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type webChatRequest struct { + Messages []webChatMessage `json:"messages"` + Model string `json:"model"` +} + func main() { // 1. Setup Dependencies // Ensure you have run: `ollama pull qwen2.5:7b-instruct` @@ -59,6 +131,11 @@ func main() { // 2. Setup HTTP Multiplexer mux := http.NewServeMux() mux.HandleFunc("/chat", app.handleChat) + mux.HandleFunc("/web-chat", webChatMainPage) + mux.HandleFunc("/login", webChatLogin) + mux.HandleFunc("/validate-api-key", webChatValidateApiKey) + mux.HandleFunc("/web-chat/send", webChatSend) // proxies messages to OpenAI + mux.HandleFunc("/logout", webChatLogout) // clears session cookie server := &http.Server{ Addr: ":8080", @@ -171,3 +248,173 @@ func respondJSON(w http.ResponseWriter, status int, payload interface{}) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } + +// webChatMainPage serves the main chat UI. +// Redirects to /login if no valid session is present. +func webChatMainPage(w http.ResponseWriter, r *http.Request) { + if sessionAPIKey(r) == "" { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + http.ServeFile(w, r, "pkg/templates/index.html") +} + +// webChatSend proxies a full conversation to OpenAI and returns the reply. +// Message history lives on the client — the server stays stateless. +func webChatSend(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondJSON(w, http.StatusMethodNotAllowed, ChatResponse{Error: "POST required"}) + return + } + + apiKey := sessionAPIKey(r) + if apiKey == "" { + respondJSON(w, http.StatusUnauthorized, ChatResponse{Error: "not authenticated"}) + return + } + + var req webChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Messages) == 0 { + respondJSON(w, http.StatusBadRequest, ChatResponse{Error: "messages array is required"}) + return + } + + model := req.Model + if model == "" { + model = string(openai.ChatModelGPT4oMini) + } + + msgs := make([]openai.ChatCompletionMessageParamUnion, 0, len(req.Messages)) + for _, m := range req.Messages { + switch m.Role { + case "user": + msgs = append(msgs, openai.UserMessage(m.Content)) + case "assistant": + msgs = append(msgs, openai.AssistantMessage(m.Content)) + case "system": + msgs = append(msgs, openai.SystemMessage(m.Content)) + } + } + + client := openai.NewClient(option.WithAPIKey(apiKey)) + completion, err := client.Chat.Completions.New(r.Context(), openai.ChatCompletionNewParams{ + Model: openai.ChatModel(model), + Messages: msgs, + }) + if err != nil { + respondJSON(w, http.StatusInternalServerError, ChatResponse{Error: "OpenAI error: " + err.Error()}) + return + } + + respondJSON(w, http.StatusOK, ChatResponse{Reply: completion.Choices[0].Message.Content}) +} + +func webChatLogin(w http.ResponseWriter, r *http.Request) { + if sessionAPIKey(r) != "" { + http.Redirect(w, r, "/web-chat", http.StatusFound) + return + } + http.ServeFile(w, r, "pkg/templates/login.html") +} + +// webChatLogout clears the session cookie and redirects to /login +func webChatLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("session_id"); err == nil { + sessions.delete(cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "session_id", + Value: "", + Path: "/", + MaxAge: -1, + }) + http.Redirect(w, r, "/login", http.StatusFound) +} + +// webChatValidateApiKey validates the submitted OpenAI key and, +// on success, issues a session cookie so the browser stays logged in. +func webChatValidateApiKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondJSON(w, http.StatusMethodNotAllowed, ChatResponse{Error: "only POST method is allowed"}) + return + } + + // Support both original form encoding and JSON (for the JS fetch call) + var apiKey string + if r.Header.Get("Content-Type") == "application/json" { + var body struct { + APIKey string `json:"api_key"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respondJSON(w, http.StatusBadRequest, ChatResponse{Error: "invalid JSON"}) + return + } + apiKey = body.APIKey + } else { + apiKey = r.FormValue("api-key") + } + + if apiKey == "" { + respondJSON(w, http.StatusBadRequest, ChatResponse{Error: "api-key is required"}) + return + } + + valid, err := ValidateOpenAIKey(apiKey) + if err != nil { + respondJSON(w, http.StatusInternalServerError, ChatResponse{Error: err.Error()}) + return + } + if !valid { + respondJSON(w, http.StatusUnauthorized, ChatResponse{Error: "invalid API key"}) + return + } + + // Store key server-side; send only the opaque session ID to the browser + id := sessions.create(apiKey) + http.SetCookie(w, &http.Cookie{ + Name: "session_id", + Value: id, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 86400, // 24 h + }) + respondJSON(w, http.StatusOK, ChatResponse{Reply: fmt.Sprintf("API key is valid: %v", valid)}) +} + +// sessionAPIKey returns the OpenAI key stored for the current browser session, +// or "" if the request carries no valid session cookie. +func sessionAPIKey(r *http.Request) string { + cookie, err := r.Cookie("session_id") + if err != nil { + return "" + } + key, _ := sessions.get(cookie.Value) + return key +} + +// help func for check key validate +func ValidateOpenAIKey(apiKey string) (bool, error) { + + if apiKey == "" { + return false, errors.New("API key cannot be empty") + } + + client := openai.NewClient( + option.WithAPIKey(apiKey), + option.WithHTTPClient(&http.Client{ + Timeout: 10 * time.Second, + }), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := client.Models.List(ctx) + + if err == nil { + return true, nil + } + + return false, fmt.Errorf("not valide API key %s: %w", apiKey, err) +} diff --git a/go.mod b/go.mod index ef82ef7..8d6e09f 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,12 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/openai/openai-go v1.12.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect go.etcd.io/bbolt v1.4.3 // indirect go.uber.org/atomic v1.11.0 // indirect diff --git a/go.sum b/go.sum index f6b6890..4edc5c9 100644 --- a/go.sum +++ b/go.sum @@ -33,12 +33,24 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y= github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= +github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= +github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= 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/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -49,12 +61,14 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= diff --git a/pkg/templates/index.html b/pkg/templates/index.html new file mode 100644 index 0000000..519b638 --- /dev/null +++ b/pkg/templates/index.html @@ -0,0 +1,877 @@ + + +
+ + ++ Enter to send · Shift+Enter for new line +
++ Your key is validated once and stored only in server memory — + never on disk or sent back to the browser. +
+ + ++ Don't have a key? + Get one here ↗ +
+ + + + + + +