Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
max-same-issues: 0
247 changes: 247 additions & 0 deletions cmd/examples/httpserver/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package main

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"

Expand All @@ -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).
Expand All @@ -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`
Expand All @@ -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",
Expand Down Expand Up @@ -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)
}
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
Loading