diff options
| author | adamdottv <[email protected]> | 2025-05-29 15:37:06 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-05-29 15:37:06 -0500 |
| commit | 0e31bbcd9322e1f667b87c88445a4f6effa1d934 (patch) | |
| tree | 428bbd368a197cb3e80523c629437d8cde86b092 /internal/message | |
| parent | 913b3434d8243cc9681a3bf7520e7b027ec3853b (diff) | |
| download | opencode-0e31bbcd9322e1f667b87c88445a4f6effa1d934.tar.gz opencode-0e31bbcd9322e1f667b87c88445a4f6effa1d934.zip | |
wip: refactoring tui
Diffstat (limited to 'internal/message')
| -rw-r--r-- | internal/message/attachment.go | 8 | ||||
| -rw-r--r-- | internal/message/content.go | 323 | ||||
| -rw-r--r-- | internal/message/message.go | 498 |
3 files changed, 0 insertions, 829 deletions
diff --git a/internal/message/attachment.go b/internal/message/attachment.go deleted file mode 100644 index 6e89f0014..000000000 --- a/internal/message/attachment.go +++ /dev/null @@ -1,8 +0,0 @@ -package message - -type Attachment struct { - FilePath string - FileName string - MimeType string - Content []byte -} diff --git a/internal/message/content.go b/internal/message/content.go deleted file mode 100644 index 607a43272..000000000 --- a/internal/message/content.go +++ /dev/null @@ -1,323 +0,0 @@ -package message - -import ( - "encoding/base64" - "slices" - "time" -) - -type MessageRole string - -const ( - Assistant MessageRole = "assistant" - User MessageRole = "user" - System MessageRole = "system" - Tool MessageRole = "tool" -) - -type FinishReason string - -const ( - FinishReasonEndTurn FinishReason = "end_turn" - FinishReasonMaxTokens FinishReason = "max_tokens" - FinishReasonToolUse FinishReason = "tool_use" - FinishReasonCanceled FinishReason = "canceled" - FinishReasonError FinishReason = "error" - FinishReasonPermissionDenied FinishReason = "permission_denied" - - // Should never happen - FinishReasonUnknown FinishReason = "unknown" -) - -type ContentPart interface { - isPart() -} - -type ReasoningContent struct { - Thinking string `json:"thinking"` -} - -func (tc ReasoningContent) String() string { - return tc.Thinking -} -func (ReasoningContent) isPart() {} - -type TextContent struct { - Text string `json:"text"` -} - -func (tc *TextContent) String() string { - if tc == nil { - return "" - } - return tc.Text -} - -func (TextContent) isPart() {} - -type ImageURLContent struct { - URL string `json:"url"` - Detail string `json:"detail,omitempty"` -} - -func (iuc ImageURLContent) String() string { - return iuc.URL -} - -func (ImageURLContent) isPart() {} - -type BinaryContent struct { - Path string - MIMEType string - Data []byte -} - -func (bc BinaryContent) String(provider string) string { - base64Encoded := base64.StdEncoding.EncodeToString(bc.Data) - // if provider == models.ProviderOpenAI { - // return "data:" + bc.MIMEType + ";base64," + base64Encoded - // } - return base64Encoded -} - -func (BinaryContent) isPart() {} - -type ToolCall struct { - ID string `json:"id"` - Name string `json:"name"` - Input string `json:"input"` - Type string `json:"type"` - Finished bool `json:"finished"` -} - -func (ToolCall) isPart() {} - -type ToolResult struct { - ToolCallID string `json:"tool_call_id"` - Name string `json:"name"` - Content string `json:"content"` - Metadata string `json:"metadata"` - IsError bool `json:"is_error"` -} - -func (ToolResult) isPart() {} - -type Finish struct { - Reason FinishReason `json:"reason"` - Time time.Time `json:"time"` -} - -type DBFinish struct { - Reason FinishReason `json:"reason"` - Time int64 `json:"time"` -} - -func (Finish) isPart() {} - -func (m *Message) Content() *TextContent { - for _, part := range m.Parts { - if c, ok := part.(TextContent); ok { - return &c - } - } - return nil -} - -func (m *Message) ReasoningContent() ReasoningContent { - for _, part := range m.Parts { - if c, ok := part.(ReasoningContent); ok { - return c - } - } - return ReasoningContent{} -} - -func (m *Message) ImageURLContent() []ImageURLContent { - imageURLContents := make([]ImageURLContent, 0) - for _, part := range m.Parts { - if c, ok := part.(ImageURLContent); ok { - imageURLContents = append(imageURLContents, c) - } - } - return imageURLContents -} - -func (m *Message) BinaryContent() []BinaryContent { - binaryContents := make([]BinaryContent, 0) - for _, part := range m.Parts { - if c, ok := part.(BinaryContent); ok { - binaryContents = append(binaryContents, c) - } - } - return binaryContents -} - -func (m *Message) ToolCalls() []ToolCall { - toolCalls := make([]ToolCall, 0) - for _, part := range m.Parts { - if c, ok := part.(ToolCall); ok { - toolCalls = append(toolCalls, c) - } - } - return toolCalls -} - -func (m *Message) ToolResults() []ToolResult { - toolResults := make([]ToolResult, 0) - for _, part := range m.Parts { - if c, ok := part.(ToolResult); ok { - toolResults = append(toolResults, c) - } - } - return toolResults -} - -func (m *Message) IsFinished() bool { - for _, part := range m.Parts { - if _, ok := part.(Finish); ok { - return true - } - } - return false -} - -func (m *Message) FinishPart() *Finish { - for _, part := range m.Parts { - if c, ok := part.(Finish); ok { - return &c - } - } - return nil -} - -func (m *Message) FinishReason() FinishReason { - for _, part := range m.Parts { - if c, ok := part.(Finish); ok { - return c.Reason - } - } - return "" -} - -func (m *Message) IsThinking() bool { - if m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished() { - return true - } - return false -} - -func (m *Message) AppendContent(delta string) { - found := false - for i, part := range m.Parts { - if c, ok := part.(TextContent); ok { - m.Parts[i] = TextContent{Text: c.Text + delta} - found = true - } - } - if !found { - m.Parts = append(m.Parts, TextContent{Text: delta}) - } -} - -func (m *Message) AppendReasoningContent(delta string) { - found := false - for i, part := range m.Parts { - if c, ok := part.(ReasoningContent); ok { - m.Parts[i] = ReasoningContent{Thinking: c.Thinking + delta} - found = true - } - } - if !found { - m.Parts = append(m.Parts, ReasoningContent{Thinking: delta}) - } -} - -func (m *Message) FinishToolCall(toolCallID string) { - for i, part := range m.Parts { - if c, ok := part.(ToolCall); ok { - if c.ID == toolCallID { - m.Parts[i] = ToolCall{ - ID: c.ID, - Name: c.Name, - Input: c.Input, - Type: c.Type, - Finished: true, - } - return - } - } - } -} - -func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) { - for i, part := range m.Parts { - if c, ok := part.(ToolCall); ok { - if c.ID == toolCallID { - m.Parts[i] = ToolCall{ - ID: c.ID, - Name: c.Name, - Input: c.Input + inputDelta, - Type: c.Type, - Finished: c.Finished, - } - return - } - } - } -} - -func (m *Message) AddToolCall(tc ToolCall) { - for i, part := range m.Parts { - if c, ok := part.(ToolCall); ok { - if c.ID == tc.ID { - m.Parts[i] = tc - return - } - } - } - m.Parts = append(m.Parts, tc) -} - -func (m *Message) SetToolCalls(tc []ToolCall) { - // remove any existing tool call part it could have multiple - parts := make([]ContentPart, 0) - for _, part := range m.Parts { - if _, ok := part.(ToolCall); ok { - continue - } - parts = append(parts, part) - } - m.Parts = parts - for _, toolCall := range tc { - m.Parts = append(m.Parts, toolCall) - } -} - -func (m *Message) AddToolResult(tr ToolResult) { - m.Parts = append(m.Parts, tr) -} - -func (m *Message) SetToolResults(tr []ToolResult) { - for _, toolResult := range tr { - m.Parts = append(m.Parts, toolResult) - } -} - -func (m *Message) AddFinish(reason FinishReason) { - // remove any existing finish part - for i, part := range m.Parts { - if _, ok := part.(Finish); ok { - m.Parts = slices.Delete(m.Parts, i, i+1) - break - } - } - m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now()}) -} - -func (m *Message) AddImageURL(url, detail string) { - m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail}) -} - -func (m *Message) AddBinary(mimeType string, data []byte) { - m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data}) -} diff --git a/internal/message/message.go b/internal/message/message.go deleted file mode 100644 index 2c05d53bf..000000000 --- a/internal/message/message.go +++ /dev/null @@ -1,498 +0,0 @@ -package message - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "log/slog" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/sst/opencode/internal/db" - "github.com/sst/opencode/internal/pubsub" -) - -type Message struct { - ID string - Role MessageRole - SessionID string - Parts []ContentPart - CreatedAt time.Time - UpdatedAt time.Time -} - -const ( - EventMessageCreated pubsub.EventType = "message_created" - EventMessageUpdated pubsub.EventType = "message_updated" - EventMessageDeleted pubsub.EventType = "message_deleted" -) - -type CreateMessageParams struct { - Role MessageRole - Parts []ContentPart -} - -type Service interface { - pubsub.Subscriber[Message] - - Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) - Update(ctx context.Context, message Message) (Message, error) - Get(ctx context.Context, id string) (Message, error) - List(ctx context.Context, sessionID string) ([]Message, error) - ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]Message, error) - Delete(ctx context.Context, id string) error - DeleteSessionMessages(ctx context.Context, sessionID string) error -} - -type service struct { - db *db.Queries - broker *pubsub.Broker[Message] - mu sync.RWMutex -} - -var globalMessageService *service - -func InitService(dbConn *sql.DB) error { - if globalMessageService != nil { - return fmt.Errorf("message service already initialized") - } - queries := db.New(dbConn) - broker := pubsub.NewBroker[Message]() - - globalMessageService = &service{ - db: queries, - broker: broker, - } - return nil -} - -func GetService() Service { - if globalMessageService == nil { - panic("message service not initialized. Call message.InitService() first.") - } - return globalMessageService -} - -func (s *service) Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) { - s.mu.Lock() - defer s.mu.Unlock() - - isFinished := false - for _, p := range params.Parts { - if _, ok := p.(Finish); ok { - isFinished = true - break - } - } - if params.Role == User && !isFinished { - params.Parts = append(params.Parts, Finish{Reason: FinishReasonEndTurn, Time: time.Now()}) - } - - partsJSON, err := marshallParts(params.Parts) - if err != nil { - return Message{}, fmt.Errorf("failed to marshal message parts: %w", err) - } - - dbMsgParams := db.CreateMessageParams{ - ID: uuid.New().String(), - SessionID: sessionID, - Role: string(params.Role), - Parts: string(partsJSON), - } - - dbMessage, err := s.db.CreateMessage(ctx, dbMsgParams) - if err != nil { - return Message{}, fmt.Errorf("db.CreateMessage: %w", err) - } - - message, err := s.fromDBItem(dbMessage) - if err != nil { - return Message{}, fmt.Errorf("failed to convert DB message: %w", err) - } - - s.broker.Publish(EventMessageCreated, message) - return message, nil -} - -func (s *service) Update(ctx context.Context, message Message) (Message, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if message.ID == "" { - return Message{}, fmt.Errorf("cannot update message with empty ID") - } - - partsJSON, err := marshallParts(message.Parts) - if err != nil { - return Message{}, fmt.Errorf("failed to marshal message parts for update: %w", err) - } - - var dbFinishedAt sql.NullString - finishPart := message.FinishPart() - if finishPart != nil && !finishPart.Time.IsZero() { - dbFinishedAt = sql.NullString{ - String: finishPart.Time.UTC().Format(time.RFC3339Nano), - Valid: true, - } - } - - // UpdatedAt is handled by the DB trigger (strftime('%s', 'now')) - err = s.db.UpdateMessage(ctx, db.UpdateMessageParams{ - ID: message.ID, - Parts: string(partsJSON), - FinishedAt: dbFinishedAt, - }) - if err != nil { - return Message{}, fmt.Errorf("db.UpdateMessage: %w", err) - } - - dbUpdatedMessage, err := s.db.GetMessage(ctx, message.ID) - if err != nil { - return Message{}, fmt.Errorf("failed to fetch message after update: %w", err) - } - updatedMessage, err := s.fromDBItem(dbUpdatedMessage) - if err != nil { - return Message{}, fmt.Errorf("failed to convert updated DB message: %w", err) - } - - s.broker.Publish(EventMessageUpdated, updatedMessage) - return updatedMessage, nil -} - -func (s *service) Get(ctx context.Context, id string) (Message, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - dbMessage, err := s.db.GetMessage(ctx, id) - if err != nil { - if err == sql.ErrNoRows { - return Message{}, fmt.Errorf("message with ID '%s' not found", id) - } - return Message{}, fmt.Errorf("db.GetMessage: %w", err) - } - return s.fromDBItem(dbMessage) -} - -func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - dbMessages, err := s.db.ListMessagesBySession(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("db.ListMessagesBySession: %w", err) - } - messages := make([]Message, len(dbMessages)) - for i, dbMsg := range dbMessages { - msg, convErr := s.fromDBItem(dbMsg) - if convErr != nil { - return nil, fmt.Errorf("failed to convert DB message at index %d: %w", i, convErr) - } - messages[i] = msg - } - return messages, nil -} - -func (s *service) ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]Message, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - dbMessages, err := s.db.ListMessagesBySessionAfter(ctx, db.ListMessagesBySessionAfterParams{ - SessionID: sessionID, - CreatedAt: timestamp.Format(time.RFC3339Nano), - }) - if err != nil { - return nil, fmt.Errorf("db.ListMessagesBySessionAfter: %w", err) - } - messages := make([]Message, len(dbMessages)) - for i, dbMsg := range dbMessages { - msg, convErr := s.fromDBItem(dbMsg) - if convErr != nil { - return nil, fmt.Errorf("failed to convert DB message at index %d (ListAfter): %w", i, convErr) - } - messages[i] = msg - } - return messages, nil -} - -func (s *service) Delete(ctx context.Context, id string) error { - s.mu.Lock() - messageToPublish, err := s.getServiceForPublish(ctx, id) - s.mu.Unlock() - - if err != nil { - // If error was due to not found, it's not a critical failure for deletion intent - if strings.Contains(err.Error(), "not found") { - return nil // Or return the error if strictness is required - } - return err - } - - s.mu.Lock() - defer s.mu.Unlock() - err = s.db.DeleteMessage(ctx, id) - if err != nil { - return fmt.Errorf("db.DeleteMessage: %w", err) - } - - if messageToPublish != nil { - s.broker.Publish(EventMessageDeleted, *messageToPublish) - } - return nil -} - -func (s *service) getServiceForPublish(ctx context.Context, id string) (*Message, error) { - dbMsg, err := s.db.GetMessage(ctx, id) - if err != nil { - return nil, err - } - msg, convErr := s.fromDBItem(dbMsg) - if convErr != nil { - return nil, fmt.Errorf("failed to convert DB message for publishing: %w", convErr) - } - return &msg, nil -} - -func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - messagesToDelete, err := s.db.ListMessagesBySession(ctx, sessionID) - if err != nil { - return fmt.Errorf("failed to list messages for deletion: %w", err) - } - - err = s.db.DeleteSessionMessages(ctx, sessionID) - if err != nil { - return fmt.Errorf("db.DeleteSessionMessages: %w", err) - } - - for _, dbMsg := range messagesToDelete { - msg, convErr := s.fromDBItem(dbMsg) - if convErr == nil { - s.broker.Publish(EventMessageDeleted, msg) - } else { - slog.Error("Failed to convert DB message for delete event publishing", "id", dbMsg.ID, "error", convErr) - } - } - return nil -} - -func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Message] { - return s.broker.Subscribe(ctx) -} - -func (s *service) fromDBItem(item db.Message) (Message, error) { - parts, err := unmarshallParts([]byte(item.Parts)) - if err != nil { - return Message{}, fmt.Errorf("unmarshallParts for message ID %s: %w. Raw parts: %s", item.ID, err, item.Parts) - } - - // Parse timestamps from ISO strings - createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt) - if err != nil { - slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err) - createdAt = time.Now() // Fallback - } - - updatedAt, err := time.Parse(time.RFC3339Nano, item.UpdatedAt) - if err != nil { - slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err) - updatedAt = time.Now() // Fallback - } - - msg := Message{ - ID: item.ID, - SessionID: item.SessionID, - Role: MessageRole(item.Role), - Parts: parts, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - return msg, nil -} - -func Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) { - return GetService().Create(ctx, sessionID, params) -} - -func Update(ctx context.Context, message Message) (Message, error) { - return GetService().Update(ctx, message) -} - -func Get(ctx context.Context, id string) (Message, error) { - return GetService().Get(ctx, id) -} - -func List(ctx context.Context, sessionID string) ([]Message, error) { - return GetService().List(ctx, sessionID) -} - -func ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]Message, error) { - return GetService().ListAfter(ctx, sessionID, timestamp) -} - -func Delete(ctx context.Context, id string) error { - return GetService().Delete(ctx, id) -} - -func DeleteSessionMessages(ctx context.Context, sessionID string) error { - return GetService().DeleteSessionMessages(ctx, sessionID) -} - -func Subscribe(ctx context.Context) <-chan pubsub.Event[Message] { - return GetService().Subscribe(ctx) -} - -type partType string - -const ( - reasoningType partType = "reasoning" - textType partType = "text" - imageURLType partType = "image_url" - binaryType partType = "binary" - toolCallType partType = "tool_call" - toolResultType partType = "tool_result" - finishType partType = "finish" -) - -type partWrapper struct { - Type partType `json:"type"` - Data json.RawMessage `json:"data"` -} - -func marshallParts(parts []ContentPart) ([]byte, error) { - wrappedParts := make([]json.RawMessage, len(parts)) - for i, part := range parts { - var typ partType - var dataBytes []byte - var err error - - switch p := part.(type) { - case ReasoningContent: - typ = reasoningType - dataBytes, err = json.Marshal(p) - case TextContent: - typ = textType - dataBytes, err = json.Marshal(p) - case *TextContent: - typ = textType - dataBytes, err = json.Marshal(p) - case ImageURLContent: - typ = imageURLType - dataBytes, err = json.Marshal(p) - case BinaryContent: - typ = binaryType - dataBytes, err = json.Marshal(p) - case ToolCall: - typ = toolCallType - dataBytes, err = json.Marshal(p) - case ToolResult: - typ = toolResultType - dataBytes, err = json.Marshal(p) - case Finish: - typ = finishType - var dbFinish DBFinish - dbFinish.Reason = p.Reason - dbFinish.Time = p.Time.UnixMilli() - dataBytes, err = json.Marshal(dbFinish) - default: - return nil, fmt.Errorf("unknown part type for marshalling: %T", part) - } - if err != nil { - return nil, fmt.Errorf("failed to marshal part data for type %s: %w", typ, err) - } - wrapper := struct { - Type partType `json:"type"` - Data json.RawMessage `json:"data"` - }{Type: typ, Data: dataBytes} - wrappedBytes, err := json.Marshal(wrapper) - if err != nil { - return nil, fmt.Errorf("failed to marshal part wrapper for type %s: %w", typ, err) - } - wrappedParts[i] = wrappedBytes - } - return json.Marshal(wrappedParts) -} - -func unmarshallParts(data []byte) ([]ContentPart, error) { - var rawMessages []json.RawMessage - if err := json.Unmarshal(data, &rawMessages); err != nil { - return nil, fmt.Errorf("failed to unmarshal parts data as array: %w. Data: %s", err, string(data)) - } - - parts := make([]ContentPart, 0, len(rawMessages)) - for _, rawPart := range rawMessages { - var wrapper partWrapper - if err := json.Unmarshal(rawPart, &wrapper); err != nil { - // Fallback for old format where parts might be just TextContent string - var text string - if errText := json.Unmarshal(rawPart, &text); errText == nil { - parts = append(parts, TextContent{Text: text}) - continue - } - return nil, fmt.Errorf("failed to unmarshal part wrapper: %w. Raw part: %s", err, string(rawPart)) - } - - switch wrapper.Type { - case reasoningType: - var p ReasoningContent - if err := json.Unmarshal(wrapper.Data, &p); err != nil { - return nil, fmt.Errorf("unmarshal ReasoningContent: %w. Data: %s", err, string(wrapper.Data)) - } - parts = append(parts, p) - case textType: - var p TextContent - if err := json.Unmarshal(wrapper.Data, &p); err != nil { - return nil, fmt.Errorf("unmarshal TextContent: %w. Data: %s", err, string(wrapper.Data)) - } - parts = append(parts, p) - case imageURLType: - var p ImageURLContent - if err := json.Unmarshal(wrapper.Data, &p); err != nil { - return nil, fmt.Errorf("unmarshal ImageURLContent: %w. Data: %s", err, string(wrapper.Data)) - } - parts = append(parts, p) - case binaryType: - var p BinaryContent - if err := json.Unmarshal(wrapper.Data, &p); err != nil { - return nil, fmt.Errorf("unmarshal BinaryContent: %w. Data: %s", err, string(wrapper.Data)) - } - parts = append(parts, p) - case toolCallType: - var p ToolCall - if err := json.Unmarshal(wrapper.Data, &p); err != nil { - return nil, fmt.Errorf("unmarshal ToolCall: %w. Data: %s", err, string(wrapper.Data)) - } - parts = append(parts, p) - case toolResultType: - var p ToolResult - if err := json.Unmarshal(wrapper.Data, &p); err != nil { - return nil, fmt.Errorf("unmarshal ToolResult: %w. Data: %s", err, string(wrapper.Data)) - } - parts = append(parts, p) - case finishType: - var p DBFinish - if err := json.Unmarshal(wrapper.Data, &p); err != nil { - return nil, fmt.Errorf("unmarshal Finish: %w. Data: %s", err, string(wrapper.Data)) - } - parts = append(parts, Finish{Reason: FinishReason(p.Reason), Time: time.UnixMilli(p.Time)}) - default: - slog.Warn("Unknown part type during unmarshalling, attempting to parse as TextContent", "type", wrapper.Type, "data", string(wrapper.Data)) - // Fallback: if type is unknown or empty, try to parse data as TextContent directly - var p TextContent - if err := json.Unmarshal(wrapper.Data, &p); err == nil { - parts = append(parts, p) - } else { - // If that also fails, log it but continue if possible, or return error - slog.Error("Failed to unmarshal unknown part type and fallback to TextContent failed", "type", wrapper.Type, "data", string(wrapper.Data), "error", err) - // Depending on strictness, you might return an error here: - // return nil, fmt.Errorf("unknown part type '%s' and failed fallback: %w", wrapper.Type, err) - } - } - } - return parts, nil -} |
