This commit is contained in:
17
services/conversation/Dockerfile
Normal file
17
services/conversation/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/conversation/ services/conversation/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/conversation-server ./services/conversation/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/conversation-server ./conversation-server
|
||||
COPY --from=build /src/services/conversation/migrations ./migrations
|
||||
ENV ENKLY_CONVERSATION_GRPC_ADDR=:9003
|
||||
EXPOSE 9003
|
||||
ENTRYPOINT ["./conversation-server"]
|
||||
98
services/conversation/cmd/server/main.go
Normal file
98
services/conversation/cmd/server/main.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Command server runs the conversation microservice: a gRPC server
|
||||
// exposing api/proto/conversation/v1, backed by its own Postgres
|
||||
// database. Replaces internal/db/conversations.go's callers.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
conversationv1 "enkly/gen/conversation/v1"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/conversation/internal/application"
|
||||
"enkly/services/conversation/internal/infrastructure/postgres"
|
||||
grpcsrv "enkly/services/conversation/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
if err := envkit.LoadDotEnv(".env"); err != nil {
|
||||
logger.Error("load .env", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var (
|
||||
grpcAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", ":9003")
|
||||
postgresDSN = envkit.String("ENKLY_CONVERSATION_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_conversation?sslmode=disable")
|
||||
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
|
||||
migrationsPath = envkit.String("ENKLY_CONVERSATION_MIGRATIONS", "migrations/0001_init.sql")
|
||||
)
|
||||
|
||||
db, err := pgkit.Open(postgresDSN)
|
||||
if err != nil {
|
||||
logger.Error("open postgres", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
migrationSQL, err := os.ReadFile(migrationsPath)
|
||||
if err != nil {
|
||||
logger.Error("read migrations", "path", migrationsPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := db.Migrate(context.Background(), string(migrationSQL)); err != nil {
|
||||
logger.Error("migrate", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
bus, err := eventbus.Connect(rabbitURL, logger)
|
||||
if err != nil {
|
||||
logger.Error("connect rabbitmq", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer bus.Close()
|
||||
|
||||
svc := &application.Service{
|
||||
Conversations: postgres.NewConversationRepo(db),
|
||||
Messages: postgres.NewMessageRepo(db),
|
||||
Events: bus,
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
conversationv1.RegisterConversationServiceServer(grpcServer, grpcsrv.New(svc))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("conversation service listening", "addr", grpcAddr)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
logger.Error("serve", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-ctx.Done()
|
||||
logger.Info("shutting down")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
75
services/conversation/internal/application/service.go
Normal file
75
services/conversation/internal/application/service.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Package application holds conversation's use cases. Thin by design —
|
||||
// the monolith's own conversation handling was mostly direct db calls
|
||||
// with little business logic beyond ownership scoping, which the
|
||||
// repository layer already enforces (see domain.ConversationRepository's
|
||||
// doc comment).
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/eventbus/events"
|
||||
"enkly/services/conversation/internal/domain"
|
||||
)
|
||||
|
||||
type Publisher interface {
|
||||
PublishBestEffort(ctx context.Context, routingKey string, payload any)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Conversations domain.ConversationRepository
|
||||
Messages domain.MessageRepository
|
||||
Events Publisher
|
||||
}
|
||||
|
||||
func (s *Service) CreateConversation(ctx context.Context, userID int64, title string) (*domain.Conversation, error) {
|
||||
return s.Conversations.Create(ctx, userID, title)
|
||||
}
|
||||
|
||||
func (s *Service) GetConversation(ctx context.Context, userID, id int64) (*domain.Conversation, error) {
|
||||
return s.Conversations.Get(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) ListConversations(ctx context.Context, userID int64) ([]*domain.Conversation, error) {
|
||||
return s.Conversations.List(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) MostRecentConversation(ctx context.Context, userID int64) (*domain.Conversation, error) {
|
||||
return s.Conversations.MostRecent(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) RenameConversation(ctx context.Context, userID, id int64, title string) (*domain.Conversation, error) {
|
||||
return s.Conversations.Rename(ctx, userID, id, title)
|
||||
}
|
||||
|
||||
func (s *Service) AutoTitleConversation(ctx context.Context, userID, id int64, title string) (bool, error) {
|
||||
return s.Conversations.AutoTitle(ctx, userID, id, title)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteConversation(ctx context.Context, userID, id int64) error {
|
||||
return s.Conversations.Delete(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) CountMessages(ctx context.Context, conversationID int64) (int, error) {
|
||||
return s.Conversations.CountMessages(ctx, conversationID)
|
||||
}
|
||||
|
||||
func (s *Service) AddMessage(ctx context.Context, conversationID int64, role domain.Role, content, toolName string) (*domain.Message, error) {
|
||||
m, err := s.Messages.Add(ctx, conversationID, role, content, toolName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyMessageCreated, events.MessageCreated{
|
||||
ConversationID: conversationID, MessageID: m.ID, Role: string(role),
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListMessages(ctx context.Context, conversationID int64) ([]*domain.Message, error) {
|
||||
return s.Messages.List(ctx, conversationID)
|
||||
}
|
||||
|
||||
func (s *Service) AddToolCall(ctx context.Context, tc domain.ToolCall) error {
|
||||
return s.Messages.AddToolCall(ctx, tc)
|
||||
}
|
||||
70
services/conversation/internal/domain/conversation.go
Normal file
70
services/conversation/internal/domain/conversation.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Package domain is the Conversation bounded context's entities and
|
||||
// repository interfaces — transplanted from internal/db/conversations.go.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("conversation: not found")
|
||||
|
||||
type Conversation struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Title string
|
||||
CreatedAt time.Time
|
||||
LastMessageAt time.Time
|
||||
}
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"
|
||||
RoleSystem Role = "system"
|
||||
RoleTool Role = "tool"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID int64
|
||||
ConversationID int64
|
||||
Role Role
|
||||
Content string
|
||||
ToolName string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
MessageID int64
|
||||
ToolName string
|
||||
ArgumentsJSON string
|
||||
Result string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// ConversationRepository persists Conversation rows, scoped to a
|
||||
// user_id on every read/write the same way db.conversations.go always
|
||||
// did — a conversation belonging to another user is simply ErrNotFound,
|
||||
// never a separate "forbidden" case, matching the monolith's behavior.
|
||||
type ConversationRepository interface {
|
||||
Create(ctx context.Context, userID int64, title string) (*Conversation, error)
|
||||
Get(ctx context.Context, userID, conversationID int64) (*Conversation, error)
|
||||
List(ctx context.Context, userID int64) ([]*Conversation, error)
|
||||
MostRecent(ctx context.Context, userID int64) (*Conversation, error)
|
||||
Rename(ctx context.Context, userID, conversationID int64, title string) (*Conversation, error)
|
||||
// AutoTitle sets title only if the conversation's title still
|
||||
// equals the default ("New conversation"), reporting whether it
|
||||
// actually changed it.
|
||||
AutoTitle(ctx context.Context, userID, conversationID int64, title string) (bool, error)
|
||||
Delete(ctx context.Context, userID, conversationID int64) error
|
||||
CountMessages(ctx context.Context, conversationID int64) (int, error)
|
||||
}
|
||||
|
||||
// MessageRepository persists Message and ToolCall rows.
|
||||
type MessageRepository interface {
|
||||
Add(ctx context.Context, conversationID int64, role Role, content, toolName string) (*Message, error)
|
||||
List(ctx context.Context, conversationID int64) ([]*Message, error)
|
||||
AddToolCall(ctx context.Context, tc ToolCall) error
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Package postgres implements conversation's domain repositories
|
||||
// against the conversation database. One-for-one transplant of
|
||||
// internal/db/conversations.go.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/conversation/internal/domain"
|
||||
)
|
||||
|
||||
// DefaultConversationTitle matches the column default in
|
||||
// migrations/0001_init.sql.
|
||||
const DefaultConversationTitle = "New conversation"
|
||||
|
||||
const conversationSelect = `
|
||||
SELECT c.id, c.user_id, c.title, c.created_at, COALESCE(lm.last, c.created_at)
|
||||
FROM conversations c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(m.created_at) AS last FROM messages m WHERE m.conversation_id = c.id
|
||||
) lm ON true`
|
||||
|
||||
func scanConversation(row interface{ Scan(...any) error }) (*domain.Conversation, error) {
|
||||
var c domain.Conversation
|
||||
if err := row.Scan(&c.ID, &c.UserID, &c.Title, &c.CreatedAt, &c.LastMessageAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
type ConversationRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewConversationRepo(db *pgkit.DB) *ConversationRepo { return &ConversationRepo{DB: db} }
|
||||
|
||||
func (r *ConversationRepo) Create(ctx context.Context, userID int64, title string) (*domain.Conversation, error) {
|
||||
if title == "" {
|
||||
title = DefaultConversationTitle
|
||||
}
|
||||
var id int64
|
||||
if err := r.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING id`,
|
||||
userID, title).Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) Get(ctx context.Context, userID, id int64) (*domain.Conversation, error) {
|
||||
return scanConversation(r.DB.QueryRowContext(ctx,
|
||||
conversationSelect+` WHERE c.id = $1 AND c.user_id = $2`, id, userID))
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) List(ctx context.Context, userID int64) ([]*domain.Conversation, error) {
|
||||
rows, err := r.DB.QueryContext(ctx,
|
||||
conversationSelect+` WHERE c.user_id = $1 ORDER BY 5 DESC, c.id DESC LIMIT 200`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.Conversation
|
||||
for rows.Next() {
|
||||
c, err := scanConversation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) MostRecent(ctx context.Context, userID int64) (*domain.Conversation, error) {
|
||||
return scanConversation(r.DB.QueryRowContext(ctx,
|
||||
conversationSelect+` WHERE c.user_id = $1 ORDER BY 5 DESC, c.id DESC LIMIT 1`, userID))
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) Rename(ctx context.Context, userID, id int64, title string) (*domain.Conversation, error) {
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`UPDATE conversations SET title = $3 WHERE id = $1 AND user_id = $2`, id, userID, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return r.Get(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) AutoTitle(ctx context.Context, userID, id int64, title string) (bool, error) {
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`UPDATE conversations SET title = $3 WHERE id = $1 AND user_id = $2 AND title = $4`,
|
||||
id, userID, title, DefaultConversationTitle)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) Delete(ctx context.Context, userID, id int64) error {
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`DELETE FROM conversations WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) CountMessages(ctx context.Context, conversationID int64) (int, error) {
|
||||
var n int
|
||||
err := r.DB.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM messages WHERE conversation_id = $1`, conversationID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/conversation/internal/domain"
|
||||
)
|
||||
|
||||
// cleanText makes arbitrary tool/model output safe for a Postgres TEXT
|
||||
// column, which rejects NUL bytes and invalid UTF-8 outright.
|
||||
func cleanText(s string) string {
|
||||
if strings.IndexByte(s, 0) >= 0 {
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
}
|
||||
return strings.ToValidUTF8(s, "\uFFFD")
|
||||
}
|
||||
|
||||
type MessageRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewMessageRepo(db *pgkit.DB) *MessageRepo { return &MessageRepo{DB: db} }
|
||||
|
||||
func (r *MessageRepo) Add(ctx context.Context, conversationID int64, role domain.Role, content, toolName string) (*domain.Message, error) {
|
||||
var tool any
|
||||
if toolName != "" {
|
||||
tool = toolName
|
||||
}
|
||||
var m domain.Message
|
||||
err := r.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO messages (conversation_id, role, content, tool_name)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, conversation_id, role, content, COALESCE(tool_name, ''), created_at`,
|
||||
conversationID, string(role), cleanText(content), tool).
|
||||
Scan(&m.ID, &m.ConversationID, &m.Role, &m.Content, &m.ToolName, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conversation: add message: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *MessageRepo) List(ctx context.Context, conversationID int64) ([]*domain.Message, error) {
|
||||
rows, err := r.DB.QueryContext(ctx, `
|
||||
SELECT id, conversation_id, role, content, COALESCE(tool_name, ''), created_at
|
||||
FROM messages WHERE conversation_id = $1 ORDER BY created_at, id`, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.Message
|
||||
for rows.Next() {
|
||||
var m domain.Message
|
||||
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Role, &m.Content, &m.ToolName, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MessageRepo) AddToolCall(ctx context.Context, tc domain.ToolCall) error {
|
||||
argsJSON := tc.ArgumentsJSON
|
||||
if argsJSON == "" {
|
||||
argsJSON = "{}"
|
||||
}
|
||||
// jsonb rejects the \u0000 escape that encoding/json emits for NUL.
|
||||
argsJSON = strings.ReplaceAll(argsJSON, `\u0000`, "")
|
||||
_, err := r.DB.ExecContext(ctx, `
|
||||
INSERT INTO tool_calls (message_id, tool_name, arguments, result, is_error)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5)`,
|
||||
tc.MessageID, tc.ToolName, argsJSON, cleanText(tc.Result), tc.IsError)
|
||||
return err
|
||||
}
|
||||
179
services/conversation/internal/interfaces/grpc/server.go
Normal file
179
services/conversation/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
conversationv1 "enkly/gen/conversation/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
"enkly/services/conversation/internal/application"
|
||||
"enkly/services/conversation/internal/domain"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
conversationv1.UnimplementedConversationServiceServer
|
||||
Svc *application.Service
|
||||
}
|
||||
|
||||
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
|
||||
|
||||
func toProtoRole(r domain.Role) conversationv1.Role {
|
||||
switch r {
|
||||
case domain.RoleUser:
|
||||
return conversationv1.Role_ROLE_USER
|
||||
case domain.RoleAssistant:
|
||||
return conversationv1.Role_ROLE_ASSISTANT
|
||||
case domain.RoleSystem:
|
||||
return conversationv1.Role_ROLE_SYSTEM
|
||||
case domain.RoleTool:
|
||||
return conversationv1.Role_ROLE_TOOL
|
||||
default:
|
||||
return conversationv1.Role_ROLE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func fromProtoRole(r conversationv1.Role) domain.Role {
|
||||
switch r {
|
||||
case conversationv1.Role_ROLE_USER:
|
||||
return domain.RoleUser
|
||||
case conversationv1.Role_ROLE_ASSISTANT:
|
||||
return domain.RoleAssistant
|
||||
case conversationv1.Role_ROLE_SYSTEM:
|
||||
return domain.RoleSystem
|
||||
case conversationv1.Role_ROLE_TOOL:
|
||||
return domain.RoleTool
|
||||
default:
|
||||
return domain.RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoConversation(c *domain.Conversation) *conversationv1.Conversation {
|
||||
return &conversationv1.Conversation{
|
||||
Id: c.ID,
|
||||
UserId: c.UserID,
|
||||
Title: c.Title,
|
||||
CreatedAt: timestamppb.New(c.CreatedAt),
|
||||
LastMessageAt: timestamppb.New(c.LastMessageAt),
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoMessage(m *domain.Message) *conversationv1.Message {
|
||||
return &conversationv1.Message{
|
||||
Id: m.ID,
|
||||
ConversationId: m.ConversationID,
|
||||
Role: toProtoRole(m.Role),
|
||||
Content: m.Content,
|
||||
ToolName: m.ToolName,
|
||||
CreatedAt: timestamppb.New(m.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return grpcerr.NotFound("conversation")
|
||||
}
|
||||
return grpcerr.Internal(err)
|
||||
}
|
||||
|
||||
func (s *Server) CreateConversation(ctx context.Context, req *conversationv1.CreateConversationRequest) (*conversationv1.Conversation, error) {
|
||||
c, err := s.Svc.CreateConversation(ctx, req.GetUserId(), req.GetTitle())
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return toProtoConversation(c), nil
|
||||
}
|
||||
|
||||
func (s *Server) GetConversation(ctx context.Context, req *conversationv1.GetConversationRequest) (*conversationv1.Conversation, error) {
|
||||
c, err := s.Svc.GetConversation(ctx, req.GetUserId(), req.GetConversationId())
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return toProtoConversation(c), nil
|
||||
}
|
||||
|
||||
func (s *Server) ListConversations(ctx context.Context, req *conversationv1.ListConversationsRequest) (*conversationv1.ListConversationsResponse, error) {
|
||||
list, err := s.Svc.ListConversations(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
resp := &conversationv1.ListConversationsResponse{}
|
||||
for _, c := range list {
|
||||
resp.Conversations = append(resp.Conversations, toProtoConversation(c))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) MostRecentConversation(ctx context.Context, req *conversationv1.MostRecentConversationRequest) (*conversationv1.Conversation, error) {
|
||||
c, err := s.Svc.MostRecentConversation(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return toProtoConversation(c), nil
|
||||
}
|
||||
|
||||
func (s *Server) RenameConversation(ctx context.Context, req *conversationv1.RenameConversationRequest) (*conversationv1.Conversation, error) {
|
||||
c, err := s.Svc.RenameConversation(ctx, req.GetUserId(), req.GetConversationId(), req.GetTitle())
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return toProtoConversation(c), nil
|
||||
}
|
||||
|
||||
func (s *Server) AutoTitleConversation(ctx context.Context, req *conversationv1.AutoTitleConversationRequest) (*conversationv1.AutoTitleConversationResponse, error) {
|
||||
changed, err := s.Svc.AutoTitleConversation(ctx, req.GetUserId(), req.GetConversationId(), req.GetTitle())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &conversationv1.AutoTitleConversationResponse{Retitled: changed}, nil
|
||||
}
|
||||
|
||||
func (s *Server) DeleteConversation(ctx context.Context, req *conversationv1.DeleteConversationRequest) (*conversationv1.DeleteConversationResponse, error) {
|
||||
if err := s.Svc.DeleteConversation(ctx, req.GetUserId(), req.GetConversationId()); err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return &conversationv1.DeleteConversationResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CountMessages(ctx context.Context, req *conversationv1.CountMessagesRequest) (*conversationv1.CountMessagesResponse, error) {
|
||||
n, err := s.Svc.CountMessages(ctx, req.GetConversationId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &conversationv1.CountMessagesResponse{Count: int32(n)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) AddMessage(ctx context.Context, req *conversationv1.AddMessageRequest) (*conversationv1.Message, error) {
|
||||
m, err := s.Svc.AddMessage(ctx, req.GetConversationId(), fromProtoRole(req.GetRole()), req.GetContent(), req.GetToolName())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return toProtoMessage(m), nil
|
||||
}
|
||||
|
||||
func (s *Server) ListMessages(ctx context.Context, req *conversationv1.ListMessagesRequest) (*conversationv1.ListMessagesResponse, error) {
|
||||
list, err := s.Svc.ListMessages(ctx, req.GetConversationId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
resp := &conversationv1.ListMessagesResponse{}
|
||||
for _, m := range list {
|
||||
resp.Messages = append(resp.Messages, toProtoMessage(m))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) AddToolCall(ctx context.Context, req *conversationv1.AddToolCallRequest) (*conversationv1.AddToolCallResponse, error) {
|
||||
err := s.Svc.AddToolCall(ctx, domain.ToolCall{
|
||||
MessageID: req.GetMessageId(),
|
||||
ToolName: req.GetToolName(),
|
||||
ArgumentsJSON: req.GetArgumentsJson(),
|
||||
Result: req.GetResult(),
|
||||
IsError: req.GetIsError(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &conversationv1.AddToolCallResponse{}, nil
|
||||
}
|
||||
38
services/conversation/migrations/0001_init.sql
Normal file
38
services/conversation/migrations/0001_init.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- =====================================================================
|
||||
-- conversation service — 0001_init.sql
|
||||
-- Database-per-service: owns `conversations`, `messages`, `tool_calls`.
|
||||
-- user_id is a bare BIGINT (see iam's migration note on why there's no
|
||||
-- cross-database foreign key here).
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
title TEXT NOT NULL DEFAULT 'New conversation',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS conversations_user_id_idx ON conversations (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
tool_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS messages_conversation_id_idx ON messages (conversation_id, created_at);
|
||||
|
||||
-- Audit log of every tool the agent executed on the host, surfaced in
|
||||
-- the UI's tool cards and useful for debugging.
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
message_id BIGINT REFERENCES messages(id) ON DELETE CASCADE,
|
||||
tool_name TEXT NOT NULL,
|
||||
arguments JSONB NOT NULL,
|
||||
result TEXT,
|
||||
is_error BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
Reference in New Issue
Block a user