diff --git a/cmd/agent/main.go b/cmd/agent/main.go index ffed7ccc..e0b908fd 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -15,10 +15,12 @@ import ( dbsqlc "github.com/memohai/memoh/internal/db/sqlc" "github.com/memohai/memoh/internal/embeddings" "github.com/memohai/memoh/internal/handlers" + "github.com/memohai/memoh/internal/history" "github.com/memohai/memoh/internal/mcp" "github.com/memohai/memoh/internal/memory" "github.com/memohai/memoh/internal/models" "github.com/memohai/memoh/internal/providers" + "github.com/memohai/memoh/internal/settings" "github.com/memohai/memoh/internal/server" ) @@ -71,8 +73,8 @@ func main() { authHandler := handlers.NewAuthHandler(conn, cfg.Auth.JWTSecret, jwtExpiresIn) - // Initialize chat resolver for both chat and memory operations - chatResolver := chat.NewResolver(modelsService, queries, cfg.AgentGateway.BaseURL(), 30*time.Second) + // Initialize chat resolver after memory service is configured. + var chatResolver *chat.Resolver // Create LLM client for memory operations (deferred model/provider selection). var llmClient memory.LLM = &lazyLLMClient{ @@ -140,6 +142,7 @@ func main() { memoryService = memory.NewService(llmClient, textEmbedder, store, resolver, textModel.ModelID, multimodalModel.ModelID) memoryHandler = handlers.NewMemoryHandler(memoryService) } + chatResolver = chat.NewResolver(modelsService, queries, memoryService, cfg.AgentGateway.BaseURL(), 30*time.Second) embeddingsHandler := handlers.NewEmbeddingsHandler(modelsService, queries) swaggerHandler := handlers.NewSwaggerHandler() chatHandler := handlers.NewChatHandler(chatResolver) @@ -148,7 +151,11 @@ func main() { providersService := providers.NewService(queries) providersHandler := handlers.NewProvidersHandler(providersService) modelsHandler := handlers.NewModelsHandler(modelsService) - srv := server.NewServer(addr, cfg.Auth.JWTSecret, pingHandler, authHandler, memoryHandler, embeddingsHandler, chatHandler, swaggerHandler, providersHandler, modelsHandler, containerdHandler) + settingsService := settings.NewService(queries) + settingsHandler := handlers.NewSettingsHandler(settingsService) + historyService := history.NewService(queries) + historyHandler := handlers.NewHistoryHandler(historyService) + srv := server.NewServer(addr, cfg.Auth.JWTSecret, pingHandler, authHandler, memoryHandler, embeddingsHandler, chatHandler, swaggerHandler, providersHandler, modelsHandler, settingsHandler, historyHandler, containerdHandler) if err := srv.Start(); err != nil { log.Fatalf("server failed: %v", err) diff --git a/db/migrations/0001_init.down.sql b/db/migrations/0001_init.down.sql index 1eb747e0..e34862bb 100644 --- a/db/migrations/0001_init.down.sql +++ b/db/migrations/0001_init.down.sql @@ -1,6 +1,9 @@ +DROP TABLE IF EXISTS user_settings; +DROP TABLE IF EXISTS history; DROP TABLE IF EXISTS lifecycle_events; DROP TABLE IF EXISTS container_versions; DROP TABLE IF EXISTS models; +DROP TABLE IF EXISTS llm_providers; DROP TABLE IF EXISTS snapshots; DROP TABLE IF EXISTS containers; DROP TABLE IF EXISTS users; diff --git a/db/migrations/0001_init.up.sql b/db/migrations/0001_init.up.sql index 06490fec..51f662c2 100644 --- a/db/migrations/0001_init.up.sql +++ b/db/migrations/0001_init.up.sql @@ -140,3 +140,9 @@ CREATE TABLE IF NOT EXISTS history ( CREATE INDEX IF NOT EXISTS idx_history_user ON history("user"); CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp); + +CREATE TABLE IF NOT EXISTS user_settings ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + max_context_load_time INTEGER NOT NULL DEFAULT 1440, + language TEXT NOT NULL DEFAULT 'Same as user input' +); diff --git a/db/queries/history.sql b/db/queries/history.sql index 1583c442..bbb00ec8 100644 --- a/db/queries/history.sql +++ b/db/queries/history.sql @@ -9,3 +9,23 @@ FROM history WHERE "user" = $1 AND timestamp >= $2 ORDER BY timestamp ASC; +-- name: GetHistoryByID :one +SELECT id, messages, timestamp, "user" +FROM history +WHERE id = $1; + +-- name: ListHistoryByUser :many +SELECT id, messages, timestamp, "user" +FROM history +WHERE "user" = $1 +ORDER BY timestamp DESC +LIMIT $2; + +-- name: DeleteHistoryByID :exec +DELETE FROM history +WHERE id = $1; + +-- name: DeleteHistoryByUser :exec +DELETE FROM history +WHERE "user" = $1; + diff --git a/db/queries/settings.sql b/db/queries/settings.sql new file mode 100644 index 00000000..ec1df7d5 --- /dev/null +++ b/db/queries/settings.sql @@ -0,0 +1,17 @@ +-- name: GetSettingsByUserID :one +SELECT user_id, max_context_load_time, language +FROM user_settings +WHERE user_id = $1; + +-- name: UpsertSettings :one +INSERT INTO user_settings (user_id, max_context_load_time, language) +VALUES ($1, $2, $3) +ON CONFLICT (user_id) DO UPDATE SET + max_context_load_time = EXCLUDED.max_context_load_time, + language = EXCLUDED.language +RETURNING user_id, max_context_load_time, language; + +-- name: DeleteSettingsByUserID :exec +DELETE FROM user_settings +WHERE user_id = $1; + diff --git a/docs/docs.go b/docs/docs.go index dc09eefe..881c08db 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -203,6 +203,188 @@ const docTemplate = `{ } } }, + "/history": { + "get": { + "description": "List history records for current user", + "tags": [ + "history" + ], + "summary": "List history records", + "parameters": [ + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history.ListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Create a history record for current user", + "tags": [ + "history" + ], + "summary": "Create history record", + "parameters": [ + { + "description": "History payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history.CreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/history.Record" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete all history records for current user", + "tags": [ + "history" + ], + "summary": "Delete all history records", + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/history/{id}": { + "get": { + "description": "Get a history record by ID (must belong to current user)", + "tags": [ + "history" + ], + "summary": "Get history record", + "parameters": [ + { + "type": "string", + "description": "History ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history.Record" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete a history record by ID (must belong to current user)", + "tags": [ + "history" + ], + "summary": "Delete history record", + "parameters": [ + { + "type": "string", + "description": "History ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/mcp/containers": { "post": { "tags": [ @@ -1429,6 +1611,135 @@ const docTemplate = `{ } } } + }, + "/settings": { + "get": { + "description": "Get agent settings for current user", + "tags": [ + "settings" + ], + "summary": "Get user settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/settings.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update or create agent settings for current user", + "tags": [ + "settings" + ], + "summary": "Update user settings", + "parameters": [ + { + "description": "Settings payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/settings.UpsertRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/settings.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Update or create agent settings for current user", + "tags": [ + "settings" + ], + "summary": "Update user settings", + "parameters": [ + { + "description": "Settings payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/settings.UpsertRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/settings.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Remove agent settings for current user", + "tags": [ + "settings" + ], + "summary": "Delete user settings", + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } } }, "definitions": { @@ -1666,6 +1977,50 @@ const docTemplate = `{ } } }, + "history.CreateRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "history.ListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/history.Record" + } + } + } + }, + "history.Record": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "timestamp": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, "memory.AddRequest": { "type": "object", "properties": { @@ -2116,6 +2471,28 @@ const docTemplate = `{ "type": "string" } } + }, + "settings.Settings": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "max_context_load_time": { + "type": "integer" + } + } + }, + "settings.UpsertRequest": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "max_context_load_time": { + "type": "integer" + } + } } } }` diff --git a/docs/swagger.json b/docs/swagger.json index 904b30c4..3a5b0d5c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -192,6 +192,188 @@ } } }, + "/history": { + "get": { + "description": "List history records for current user", + "tags": [ + "history" + ], + "summary": "List history records", + "parameters": [ + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history.ListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Create a history record for current user", + "tags": [ + "history" + ], + "summary": "Create history record", + "parameters": [ + { + "description": "History payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history.CreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/history.Record" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete all history records for current user", + "tags": [ + "history" + ], + "summary": "Delete all history records", + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/history/{id}": { + "get": { + "description": "Get a history record by ID (must belong to current user)", + "tags": [ + "history" + ], + "summary": "Get history record", + "parameters": [ + { + "type": "string", + "description": "History ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history.Record" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete a history record by ID (must belong to current user)", + "tags": [ + "history" + ], + "summary": "Delete history record", + "parameters": [ + { + "type": "string", + "description": "History ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/mcp/containers": { "post": { "tags": [ @@ -1418,6 +1600,135 @@ } } } + }, + "/settings": { + "get": { + "description": "Get agent settings for current user", + "tags": [ + "settings" + ], + "summary": "Get user settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/settings.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update or create agent settings for current user", + "tags": [ + "settings" + ], + "summary": "Update user settings", + "parameters": [ + { + "description": "Settings payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/settings.UpsertRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/settings.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Update or create agent settings for current user", + "tags": [ + "settings" + ], + "summary": "Update user settings", + "parameters": [ + { + "description": "Settings payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/settings.UpsertRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/settings.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Remove agent settings for current user", + "tags": [ + "settings" + ], + "summary": "Delete user settings", + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } } }, "definitions": { @@ -1655,6 +1966,50 @@ } } }, + "history.CreateRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "history.ListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/history.Record" + } + } + } + }, + "history.Record": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "timestamp": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, "memory.AddRequest": { "type": "object", "properties": { @@ -2105,6 +2460,28 @@ "type": "string" } } + }, + "settings.Settings": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "max_context_load_time": { + "type": "integer" + } + } + }, + "settings.UpsertRequest": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "max_context_load_time": { + "type": "integer" + } + } } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index eafafdfb..bedb9103 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -151,6 +151,35 @@ definitions: username: type: string type: object + history.CreateRequest: + properties: + messages: + items: + additionalProperties: true + type: object + type: array + type: object + history.ListResponse: + properties: + items: + items: + $ref: '#/definitions/history.Record' + type: array + type: object + history.Record: + properties: + id: + type: string + messages: + items: + additionalProperties: true + type: object + type: array + timestamp: + type: string + user_id: + type: string + type: object memory.AddRequest: properties: agent_id: @@ -454,6 +483,20 @@ definitions: name: type: string type: object + settings.Settings: + properties: + language: + type: string + max_context_load_time: + type: integer + type: object + settings.UpsertRequest: + properties: + language: + type: string + max_context_load_time: + type: integer + type: object info: contact: {} paths: @@ -582,6 +625,126 @@ paths: summary: Create embeddings tags: - embeddings + /history: + delete: + description: Delete all history records for current user + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Delete all history records + tags: + - history + get: + description: List history records for current user + parameters: + - description: Limit + in: query + name: limit + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history.ListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: List history records + tags: + - history + post: + description: Create a history record for current user + parameters: + - description: History payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/history.CreateRequest' + responses: + "201": + description: Created + schema: + $ref: '#/definitions/history.Record' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Create history record + tags: + - history + /history/{id}: + delete: + description: Delete a history record by ID (must belong to current user) + parameters: + - description: History ID + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Delete history record + tags: + - history + get: + description: Get a history record by ID (must belong to current user) + parameters: + - description: History ID + in: path + name: id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history.Record' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Get history record + tags: + - history /mcp/containers: post: parameters: @@ -1394,4 +1557,89 @@ paths: summary: Get provider by name tags: - providers + /settings: + delete: + description: Remove agent settings for current user + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Delete user settings + tags: + - settings + get: + description: Get agent settings for current user + responses: + "200": + description: OK + schema: + $ref: '#/definitions/settings.Settings' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Get user settings + tags: + - settings + post: + description: Update or create agent settings for current user + parameters: + - description: Settings payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/settings.UpsertRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/settings.Settings' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Update user settings + tags: + - settings + put: + description: Update or create agent settings for current user + parameters: + - description: Settings payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/settings.UpsertRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/settings.Settings' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Update user settings + tags: + - settings swagger: "2.0" diff --git a/internal/chat/resolver.go b/internal/chat/resolver.go index a49b5054..70306ed3 100644 --- a/internal/chat/resolver.go +++ b/internal/chat/resolver.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -12,9 +13,11 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/memohai/memoh/internal/db/sqlc" + "github.com/memohai/memoh/internal/memory" "github.com/memohai/memoh/internal/models" ) @@ -23,13 +26,14 @@ const defaultMaxContextMinutes = 24 * 60 type Resolver struct { modelsService *models.Service queries *sqlc.Queries + memoryService *memory.Service gatewayBaseURL string timeout time.Duration httpClient *http.Client streamingClient *http.Client } -func NewResolver(modelsService *models.Service, queries *sqlc.Queries, gatewayBaseURL string, timeout time.Duration) *Resolver { +func NewResolver(modelsService *models.Service, queries *sqlc.Queries, memoryService *memory.Service, gatewayBaseURL string, timeout time.Duration) *Resolver { if strings.TrimSpace(gatewayBaseURL) == "" { gatewayBaseURL = "http://127.0.0.1:8081" } @@ -40,6 +44,7 @@ func NewResolver(modelsService *models.Service, queries *sqlc.Queries, gatewayBa return &Resolver{ modelsService: modelsService, queries: queries, + memoryService: memoryService, gatewayBaseURL: gatewayBaseURL, timeout: timeout, httpClient: &http.Client{ @@ -66,7 +71,18 @@ func (r *Resolver) Chat(ctx context.Context, req ChatRequest) (ChatResponse, err return ChatResponse{}, err } - messages, err := r.loadHistoryMessages(ctx, req.UserID, req.MaxContextLoadTime) + maxContextLoadTime, language, err := r.loadUserSettings(ctx, req.UserID) + if err != nil { + return ChatResponse{}, err + } + if req.MaxContextLoadTime > 0 { + maxContextLoadTime = req.MaxContextLoadTime + } + if strings.TrimSpace(req.Language) != "" { + language = req.Language + } + + messages, err := r.loadHistoryMessages(ctx, req.UserID, maxContextLoadTime) if err != nil { return ChatResponse{}, err } @@ -82,12 +98,13 @@ func (r *Resolver) Chat(ctx context.Context, req ChatRequest) (ChatResponse, err Locale: req.Locale, Language: req.Language, MaxSteps: req.MaxSteps, - MaxContextLoadTime: normalizeMaxContextLoad(req.MaxContextLoadTime), + MaxContextLoadTime: normalizeMaxContextLoad(maxContextLoadTime), Platforms: req.Platforms, CurrentPlatform: req.CurrentPlatform, Messages: messages, Query: req.Query, } + payload.Language = language resp, err := r.postChat(ctx, payload) if err != nil { @@ -97,6 +114,9 @@ func (r *Resolver) Chat(ctx context.Context, req ChatRequest) (ChatResponse, err if err := r.storeHistory(ctx, req.UserID, req.Query, resp.Messages); err != nil { return ChatResponse{}, err } + if err := r.storeMemory(ctx, req.UserID, req.Query, resp.Messages); err != nil { + return ChatResponse{}, err + } return ChatResponse{ Messages: resp.Messages, @@ -133,7 +153,19 @@ func (r *Resolver) StreamChat(ctx context.Context, req ChatRequest) (<-chan Stre return } - messages, err := r.loadHistoryMessages(ctx, req.UserID, req.MaxContextLoadTime) + maxContextLoadTime, language, err := r.loadUserSettings(ctx, req.UserID) + if err != nil { + errChan <- err + return + } + if req.MaxContextLoadTime > 0 { + maxContextLoadTime = req.MaxContextLoadTime + } + if strings.TrimSpace(req.Language) != "" { + language = req.Language + } + + messages, err := r.loadHistoryMessages(ctx, req.UserID, maxContextLoadTime) if err != nil { errChan <- err return @@ -150,12 +182,13 @@ func (r *Resolver) StreamChat(ctx context.Context, req ChatRequest) (<-chan Stre Locale: req.Locale, Language: req.Language, MaxSteps: req.MaxSteps, - MaxContextLoadTime: normalizeMaxContextLoad(req.MaxContextLoadTime), + MaxContextLoadTime: normalizeMaxContextLoad(maxContextLoadTime), Platforms: req.Platforms, CurrentPlatform: req.CurrentPlatform, Messages: messages, Query: req.Query, } + payload.Language = language if err := r.streamChat(ctx, payload, req.UserID, req.Query, chunkChan); err != nil { errChan <- err @@ -270,6 +303,9 @@ func (r *Resolver) streamChat(ctx context.Context, payload agentGatewayRequest, if err := r.storeHistory(ctx, userID, query, parsed.Messages); err != nil { return err } + if err := r.storeMemory(ctx, userID, query, parsed.Messages); err != nil { + return err + } } if err := scanner.Err(); err != nil { @@ -345,6 +381,65 @@ func (r *Resolver) storeHistory(ctx context.Context, userID, query string, respo return err } +func (r *Resolver) storeMemory(ctx context.Context, userID, query string, responseMessages []GatewayMessage) error { + if r.memoryService == nil { + return nil + } + if strings.TrimSpace(userID) == "" { + return fmt.Errorf("user id is required") + } + if strings.TrimSpace(query) == "" && len(responseMessages) == 0 { + return nil + } + + userMessage := GatewayMessage{ + "role": "user", + "content": query, + } + messages := append([]GatewayMessage{userMessage}, responseMessages...) + memoryMessages := make([]memory.Message, 0, len(messages)) + for _, msg := range messages { + role, content := gatewayMessageToMemory(msg) + if strings.TrimSpace(content) == "" { + continue + } + memoryMessages = append(memoryMessages, memory.Message{ + Role: role, + Content: content, + }) + } + if len(memoryMessages) == 0 { + return nil + } + + _, err := r.memoryService.Add(ctx, memory.AddRequest{ + Messages: memoryMessages, + UserID: userID, + }) + return err +} + +func gatewayMessageToMemory(msg GatewayMessage) (string, string) { + role := "assistant" + if raw, ok := msg["role"].(string); ok && strings.TrimSpace(raw) != "" { + role = raw + } + if raw, ok := msg["content"]; ok { + switch v := raw.(type) { + case string: + return role, v + default: + if encoded, err := json.Marshal(v); err == nil { + return role, string(encoded) + } + } + } + if encoded, err := json.Marshal(msg); err == nil { + return role, string(encoded) + } + return role, "" +} + func (r *Resolver) selectChatModel(ctx context.Context, req ChatRequest) (models.GetResponse, sqlc.LlmProvider, error) { if r.modelsService == nil { return models.GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("models service not configured") @@ -428,6 +523,32 @@ func normalizeMaxContextLoad(value int) int { return value } +func (r *Resolver) loadUserSettings(ctx context.Context, userID string) (int, string, error) { + if r.queries == nil { + return defaultMaxContextMinutes, "Same as user input", nil + } + pgUserID, err := parseUUID(userID) + if err != nil { + return 0, "", err + } + settings, err := r.queries.GetSettingsByUserID(ctx, pgUserID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return defaultMaxContextMinutes, "Same as user input", nil + } + return 0, "", err + } + maxLoad := int(settings.MaxContextLoadTime) + if maxLoad <= 0 { + maxLoad = defaultMaxContextMinutes + } + language := strings.TrimSpace(settings.Language) + if language == "" { + language = "Same as user input" + } + return maxLoad, language, nil +} + func normalizeClientType(clientType string) (string, error) { switch strings.ToLower(strings.TrimSpace(clientType)) { case "openai": @@ -453,4 +574,3 @@ func parseUUID(id string) (pgtype.UUID, error) { copy(pgID.Bytes[:], parsed[:]) return pgID, nil } - diff --git a/internal/db/sqlc/history.sql.go b/internal/db/sqlc/history.sql.go index 7d2712fa..d775400b 100644 --- a/internal/db/sqlc/history.sql.go +++ b/internal/db/sqlc/history.sql.go @@ -71,3 +71,79 @@ func (q *Queries) ListHistoryByUserSince(ctx context.Context, arg ListHistoryByU } return items, nil } + +const getHistoryByID = `-- name: GetHistoryByID :one +SELECT id, messages, timestamp, "user" +FROM history +WHERE id = $1 +` + +func (q *Queries) GetHistoryByID(ctx context.Context, id pgtype.UUID) (History, error) { + row := q.db.QueryRow(ctx, getHistoryByID, id) + var i History + err := row.Scan( + &i.ID, + &i.Messages, + &i.Timestamp, + &i.User, + ) + return i, err +} + +const listHistoryByUser = `-- name: ListHistoryByUser :many +SELECT id, messages, timestamp, "user" +FROM history +WHERE "user" = $1 +ORDER BY timestamp DESC +LIMIT $2 +` + +type ListHistoryByUserParams struct { + User pgtype.UUID `json:"user"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListHistoryByUser(ctx context.Context, arg ListHistoryByUserParams) ([]History, error) { + rows, err := q.db.Query(ctx, listHistoryByUser, arg.User, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []History + for rows.Next() { + var i History + if err := rows.Scan( + &i.ID, + &i.Messages, + &i.Timestamp, + &i.User, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteHistoryByID = `-- name: DeleteHistoryByID :exec +DELETE FROM history +WHERE id = $1 +` + +func (q *Queries) DeleteHistoryByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteHistoryByID, id) + return err +} + +const deleteHistoryByUser = `-- name: DeleteHistoryByUser :exec +DELETE FROM history +WHERE "user" = $1 +` + +func (q *Queries) DeleteHistoryByUser(ctx context.Context, user pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteHistoryByUser, user) + return err +} diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index 123ea448..56371bb0 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -40,6 +40,12 @@ type History struct { User pgtype.UUID `json:"user"` } +type Settings struct { + UserID pgtype.UUID `json:"user_id"` + MaxContextLoadTime int32 `json:"max_context_load_time"` + Language string `json:"language"` +} + type LifecycleEvent struct { ID string `json:"id"` ContainerID string `json:"container_id"` diff --git a/internal/db/sqlc/settings.sql.go b/internal/db/sqlc/settings.sql.go new file mode 100644 index 00000000..d697d55c --- /dev/null +++ b/internal/db/sqlc/settings.sql.go @@ -0,0 +1,66 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: settings.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getSettingsByUserID = `-- name: GetSettingsByUserID :one +SELECT user_id, max_context_load_time, language +FROM user_settings +WHERE user_id = $1 +` + +func (q *Queries) GetSettingsByUserID(ctx context.Context, userID pgtype.UUID) (Settings, error) { + row := q.db.QueryRow(ctx, getSettingsByUserID, userID) + var i Settings + err := row.Scan( + &i.UserID, + &i.MaxContextLoadTime, + &i.Language, + ) + return i, err +} + +const upsertSettings = `-- name: UpsertSettings :one +INSERT INTO user_settings (user_id, max_context_load_time, language) +VALUES ($1, $2, $3) +ON CONFLICT (user_id) DO UPDATE SET + max_context_load_time = EXCLUDED.max_context_load_time, + language = EXCLUDED.language +RETURNING user_id, max_context_load_time, language +` + +type UpsertSettingsParams struct { + UserID pgtype.UUID `json:"user_id"` + MaxContextLoadTime int32 `json:"max_context_load_time"` + Language string `json:"language"` +} + +func (q *Queries) UpsertSettings(ctx context.Context, arg UpsertSettingsParams) (Settings, error) { + row := q.db.QueryRow(ctx, upsertSettings, arg.UserID, arg.MaxContextLoadTime, arg.Language) + var i Settings + err := row.Scan( + &i.UserID, + &i.MaxContextLoadTime, + &i.Language, + ) + return i, err +} + +const deleteSettingsByUserID = `-- name: DeleteSettingsByUserID :exec +DELETE FROM user_settings +WHERE user_id = $1 +` + +func (q *Queries) DeleteSettingsByUserID(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteSettingsByUserID, userID) + return err +} + diff --git a/internal/handlers/history.go b/internal/handlers/history.go new file mode 100644 index 00000000..635be0ff --- /dev/null +++ b/internal/handlers/history.go @@ -0,0 +1,173 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/memohai/memoh/internal/auth" + "github.com/memohai/memoh/internal/history" + "github.com/memohai/memoh/internal/identity" +) + +type HistoryHandler struct { + service *history.Service +} + +func NewHistoryHandler(service *history.Service) *HistoryHandler { + return &HistoryHandler{service: service} +} + +func (h *HistoryHandler) Register(e *echo.Echo) { + group := e.Group("/history") + group.POST("", h.Create) + group.GET("", h.List) + group.GET("/:id", h.Get) + group.DELETE("/:id", h.Delete) + group.DELETE("", h.DeleteAll) +} + +// Create godoc +// @Summary Create history record +// @Description Create a history record for current user +// @Tags history +// @Param payload body history.CreateRequest true "History payload" +// @Success 201 {object} history.Record +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /history [post] +func (h *HistoryHandler) Create(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + var req history.CreateRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + resp, err := h.service.Create(c.Request().Context(), userID, req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusCreated, resp) +} + +// Get godoc +// @Summary Get history record +// @Description Get a history record by ID (must belong to current user) +// @Tags history +// @Param id path string true "History ID" +// @Success 200 {object} history.Record +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /history/{id} [get] +func (h *HistoryHandler) Get(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + record, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if record.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + return c.JSON(http.StatusOK, record) +} + +// List godoc +// @Summary List history records +// @Description List history records for current user +// @Tags history +// @Param limit query int false "Limit" +// @Success 200 {object} history.ListResponse +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /history [get] +func (h *HistoryHandler) List(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + limit := 0 + if raw := c.QueryParam("limit"); raw != "" { + if _, err := fmt.Sscanf(raw, "%d", &limit); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid limit") + } + } + items, err := h.service.List(c.Request().Context(), userID, limit) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, history.ListResponse{Items: items}) +} + +// Delete godoc +// @Summary Delete history record +// @Description Delete a history record by ID (must belong to current user) +// @Tags history +// @Param id path string true "History ID" +// @Success 204 "No Content" +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /history/{id} [delete] +func (h *HistoryHandler) Delete(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + record, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if record.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + if err := h.service.Delete(c.Request().Context(), id); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.NoContent(http.StatusNoContent) +} + +// DeleteAll godoc +// @Summary Delete all history records +// @Description Delete all history records for current user +// @Tags history +// @Success 204 "No Content" +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /history [delete] +func (h *HistoryHandler) DeleteAll(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + if err := h.service.DeleteByUser(c.Request().Context(), userID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.NoContent(http.StatusNoContent) +} + +func (h *HistoryHandler) requireUserID(c echo.Context) (string, error) { + userID, err := auth.UserIDFromContext(c) + if err != nil { + return "", err + } + if err := identity.ValidateUserID(userID); err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + return userID, nil +} + diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go new file mode 100644 index 00000000..93153ff3 --- /dev/null +++ b/internal/handlers/settings.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/memohai/memoh/internal/auth" + "github.com/memohai/memoh/internal/identity" + "github.com/memohai/memoh/internal/settings" +) + +type SettingsHandler struct { + service *settings.Service +} + +func NewSettingsHandler(service *settings.Service) *SettingsHandler { + return &SettingsHandler{service: service} +} + +func (h *SettingsHandler) Register(e *echo.Echo) { + group := e.Group("/settings") + group.GET("", h.Get) + group.POST("", h.Upsert) + group.PUT("", h.Upsert) + group.DELETE("", h.Delete) +} + +// Get godoc +// @Summary Get user settings +// @Description Get agent settings for current user +// @Tags settings +// @Success 200 {object} settings.Settings +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /settings [get] +func (h *SettingsHandler) Get(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + resp, err := h.service.Get(c.Request().Context(), userID) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, resp) +} + +// Upsert godoc +// @Summary Update user settings +// @Description Update or create agent settings for current user +// @Tags settings +// @Param payload body settings.UpsertRequest true "Settings payload" +// @Success 200 {object} settings.Settings +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /settings [put] +// @Router /settings [post] +func (h *SettingsHandler) Upsert(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + var req settings.UpsertRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + resp, err := h.service.Upsert(c.Request().Context(), userID, req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, resp) +} + +// Delete godoc +// @Summary Delete user settings +// @Description Remove agent settings for current user +// @Tags settings +// @Success 204 "No Content" +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /settings [delete] +func (h *SettingsHandler) Delete(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + if err := h.service.Delete(c.Request().Context(), userID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.NoContent(http.StatusNoContent) +} + +func (h *SettingsHandler) requireUserID(c echo.Context) (string, error) { + userID, err := auth.UserIDFromContext(c) + if err != nil { + return "", err + } + if err := identity.ValidateUserID(userID); err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + return userID, nil +} + diff --git a/internal/history/service.go b/internal/history/service.go new file mode 100644 index 00000000..a9200aad --- /dev/null +++ b/internal/history/service.go @@ -0,0 +1,149 @@ +package history + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/memohai/memoh/internal/db/sqlc" +) + +const defaultListLimit = 50 + +type Service struct { + queries *sqlc.Queries +} + +func NewService(queries *sqlc.Queries) *Service { + return &Service{queries: queries} +} + +func (s *Service) Create(ctx context.Context, userID string, req CreateRequest) (Record, error) { + if len(req.Messages) == 0 { + return Record{}, fmt.Errorf("messages are required") + } + pgID, err := parseUUID(userID) + if err != nil { + return Record{}, err + } + payload, err := json.Marshal(req.Messages) + if err != nil { + return Record{}, err + } + row, err := s.queries.CreateHistory(ctx, sqlc.CreateHistoryParams{ + Messages: payload, + Timestamp: pgtype.Timestamptz{ + Time: time.Now().UTC(), + Valid: true, + }, + User: pgID, + }) + if err != nil { + return Record{}, err + } + return toRecord(row) +} + +func (s *Service) Get(ctx context.Context, id string) (Record, error) { + pgID, err := parseUUID(id) + if err != nil { + return Record{}, err + } + row, err := s.queries.GetHistoryByID(ctx, pgID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Record{}, fmt.Errorf("history not found") + } + return Record{}, err + } + return toRecord(row) +} + +func (s *Service) List(ctx context.Context, userID string, limit int) ([]Record, error) { + pgID, err := parseUUID(userID) + if err != nil { + return nil, err + } + if limit <= 0 { + limit = defaultListLimit + } + rows, err := s.queries.ListHistoryByUser(ctx, sqlc.ListHistoryByUserParams{ + User: pgID, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + items := make([]Record, 0, len(rows)) + for _, row := range rows { + record, err := toRecord(row) + if err != nil { + return nil, err + } + items = append(items, record) + } + return items, nil +} + +func (s *Service) Delete(ctx context.Context, id string) error { + pgID, err := parseUUID(id) + if err != nil { + return err + } + return s.queries.DeleteHistoryByID(ctx, pgID) +} + +func (s *Service) DeleteByUser(ctx context.Context, userID string) error { + pgID, err := parseUUID(userID) + if err != nil { + return err + } + return s.queries.DeleteHistoryByUser(ctx, pgID) +} + +func toRecord(row sqlc.History) (Record, error) { + var messages []map[string]interface{} + if len(row.Messages) > 0 { + if err := json.Unmarshal(row.Messages, &messages); err != nil { + return Record{}, err + } + } + record := Record{ + Messages: messages, + } + if row.Timestamp.Valid { + record.Timestamp = row.Timestamp.Time + } + if row.ID.Valid { + id, err := uuid.FromBytes(row.ID.Bytes[:]) + if err == nil { + record.ID = id.String() + } + } + if row.User.Valid { + uid, err := uuid.FromBytes(row.User.Bytes[:]) + if err == nil { + record.UserID = uid.String() + } + } + return record, nil +} + +func parseUUID(id string) (pgtype.UUID, error) { + parsed, err := uuid.Parse(strings.TrimSpace(id)) + if err != nil { + return pgtype.UUID{}, fmt.Errorf("invalid UUID: %w", err) + } + var pgID pgtype.UUID + pgID.Valid = true + copy(pgID.Bytes[:], parsed[:]) + return pgID, nil +} + diff --git a/internal/history/types.go b/internal/history/types.go new file mode 100644 index 00000000..72165388 --- /dev/null +++ b/internal/history/types.go @@ -0,0 +1,19 @@ +package history + +import "time" + +type Record struct { + ID string `json:"id"` + Messages []map[string]interface{} `json:"messages"` + Timestamp time.Time `json:"timestamp"` + UserID string `json:"user_id"` +} + +type CreateRequest struct { + Messages []map[string]interface{} `json:"messages"` +} + +type ListResponse struct { + Items []Record `json:"items"` +} + diff --git a/internal/server/server.go b/internal/server/server.go index 4f4c1a40..91affb96 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,7 +15,7 @@ type Server struct { addr string } -func NewServer(addr string, jwtSecret string, pingHandler *handlers.PingHandler, authHandler *handlers.AuthHandler, memoryHandler *handlers.MemoryHandler, embeddingsHandler *handlers.EmbeddingsHandler, chatHandler *handlers.ChatHandler, swaggerHandler *handlers.SwaggerHandler, providersHandler *handlers.ProvidersHandler, modelsHandler *handlers.ModelsHandler, containerdHandler *handlers.ContainerdHandler) *Server { +func NewServer(addr string, jwtSecret string, pingHandler *handlers.PingHandler, authHandler *handlers.AuthHandler, memoryHandler *handlers.MemoryHandler, embeddingsHandler *handlers.EmbeddingsHandler, chatHandler *handlers.ChatHandler, swaggerHandler *handlers.SwaggerHandler, providersHandler *handlers.ProvidersHandler, modelsHandler *handlers.ModelsHandler, settingsHandler *handlers.SettingsHandler, historyHandler *handlers.HistoryHandler, containerdHandler *handlers.ContainerdHandler) *Server { if addr == "" { addr = ":8080" } @@ -56,6 +56,12 @@ func NewServer(addr string, jwtSecret string, pingHandler *handlers.PingHandler, if swaggerHandler != nil { swaggerHandler.Register(e) } + if settingsHandler != nil { + settingsHandler.Register(e) + } + if historyHandler != nil { + historyHandler.Register(e) + } if providersHandler != nil { providersHandler.Register(e) } diff --git a/internal/settings/service.go b/internal/settings/service.go new file mode 100644 index 00000000..5b1fbb85 --- /dev/null +++ b/internal/settings/service.go @@ -0,0 +1,119 @@ +package settings + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/memohai/memoh/internal/db/sqlc" +) + +type Service struct { + queries *sqlc.Queries +} + +func NewService(queries *sqlc.Queries) *Service { + return &Service{queries: queries} +} + +func (s *Service) Get(ctx context.Context, userID string) (Settings, error) { + pgID, err := parseUUID(userID) + if err != nil { + return Settings{}, err + } + row, err := s.queries.GetSettingsByUserID(ctx, pgID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Settings{ + MaxContextLoadTime: DefaultMaxContextLoadTime, + Language: DefaultLanguage, + }, nil + } + return Settings{}, err + } + settings := Settings{ + MaxContextLoadTime: int(row.MaxContextLoadTime), + Language: strings.TrimSpace(row.Language), + } + if settings.MaxContextLoadTime <= 0 { + settings.MaxContextLoadTime = DefaultMaxContextLoadTime + } + if settings.Language == "" { + settings.Language = DefaultLanguage + } + return settings, nil +} + +func (s *Service) Upsert(ctx context.Context, userID string, req UpsertRequest) (Settings, error) { + if s.queries == nil { + return Settings{}, fmt.Errorf("settings queries not configured") + } + pgID, err := parseUUID(userID) + if err != nil { + return Settings{}, err + } + + current := Settings{ + MaxContextLoadTime: DefaultMaxContextLoadTime, + Language: DefaultLanguage, + } + existing, err := s.queries.GetSettingsByUserID(ctx, pgID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return Settings{}, err + } + if err == nil { + current.MaxContextLoadTime = int(existing.MaxContextLoadTime) + current.Language = strings.TrimSpace(existing.Language) + if current.MaxContextLoadTime <= 0 { + current.MaxContextLoadTime = DefaultMaxContextLoadTime + } + if current.Language == "" { + current.Language = DefaultLanguage + } + } + + if req.MaxContextLoadTime != nil && *req.MaxContextLoadTime > 0 { + current.MaxContextLoadTime = *req.MaxContextLoadTime + } + if strings.TrimSpace(req.Language) != "" { + current.Language = strings.TrimSpace(req.Language) + } + + _, err = s.queries.UpsertSettings(ctx, sqlc.UpsertSettingsParams{ + UserID: pgID, + MaxContextLoadTime: int32(current.MaxContextLoadTime), + Language: current.Language, + }) + if err != nil { + return Settings{}, err + } + return current, nil +} + +func (s *Service) Delete(ctx context.Context, userID string) error { + if s.queries == nil { + return fmt.Errorf("settings queries not configured") + } + pgID, err := parseUUID(userID) + if err != nil { + return err + } + return s.queries.DeleteSettingsByUserID(ctx, pgID) +} + +func parseUUID(id string) (pgtype.UUID, error) { + parsed, err := uuid.Parse(id) + if err != nil { + return pgtype.UUID{}, fmt.Errorf("invalid UUID: %w", err) + } + var pgID pgtype.UUID + pgID.Valid = true + copy(pgID.Bytes[:], parsed[:]) + return pgID, nil +} + diff --git a/internal/settings/types.go b/internal/settings/types.go new file mode 100644 index 00000000..808ff7ce --- /dev/null +++ b/internal/settings/types.go @@ -0,0 +1,17 @@ +package settings + +const ( + DefaultMaxContextLoadTime = 24 * 60 + DefaultLanguage = "Same as user input" +) + +type Settings struct { + MaxContextLoadTime int `json:"max_context_load_time"` + Language string `json:"language"` +} + +type UpsertRequest struct { + MaxContextLoadTime *int `json:"max_context_load_time,omitempty"` + Language string `json:"language,omitempty"` +} +