diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 8bc4e9fa..548c73ad 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Latest implementation checkpoint: `feat(captain): align document response actions`. -- Latest documentation checkpoint: this checkpoint, recorded with the B11.1c Captain document/assistant-response/bulk-action/custom-tool-test implementation. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads. Next active slice is B11.2 Copilot persistence and safe LLM/document gates. +- Latest implementation checkpoint: `feat(copilot): align thread message payloads`. +- Latest documentation checkpoint: this checkpoint, recorded with the B11.2 Copilot thread/message persistence and payload implementation. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence. Next active slice is B11.3 document sync, embedding, Meilisearch, and external LLM gates. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -55,7 +55,7 @@ Next ordered checkpoints: | Phase 1 | Meilisearch search engine | Review | B6 payload parity, optional live gate, and DB-fallback hardening are implemented; an actual live Meilisearch run is optional and environment-dependent | | Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback | | Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based | -| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, and B10 are in Review; B11 Captain/Copilot assistant resources are partially landed and deeper Captain/Copilot resources remain active | +| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, and B10 are in Review; B11 Captain resources and Copilot thread/message payloads are landed, while Copilot tasks/preferences/tool-call depth and document/LLM/Meilisearch gates remain active | | Phase 5 | Background jobs and integrations | Planned | durable worker choice and job parity are open | | Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad | | Phase 7 | Verification harness | Planned | search live gate and reused-frontend smoke harness are not complete | @@ -149,12 +149,13 @@ This ledger records the committed parity checkpoints that future slices should b | `feat(captain): align assistant resources` | Completed B11.1a Captain assistant resource parity for the reused dashboard assistant client: assistant list/show/create/update/delete now use raw Chatwoot/Jbuilder payloads, nested `{ assistant: ... }` bodies are accepted, assistant access is account-scoped, `/captain/assistants/tools` returns the built-in tool array, and assistant inbox bind/list/delete routes use Chatwoot `{ payload, meta }`, raw inbox, and `204` contracts. | `go test ./internal/handler/api/v1 -run CaptainAssistant -count=1`; `go test ./internal/service -run CaptainAssistant -count=1`; route dump/parity regenerated with unchanged `TOTAL: 830`; focused handler fixtures cover account scoping, tools, inbox binding, and no local `{ success, data }` envelopes. | Continue B11.1b with Captain documents, scenarios, assistant responses, bulk actions, and custom tools payload depth; B11 stays Doing until Copilot, LLM/document gates, and smoke coverage land. | | `feat(captain): align scenario and tool payloads` | Advanced B11.1b for Captain scenarios and custom tools: scenario create/list/show/update/delete now accept nested `{ scenario }`, enforce account/assistant scope, return Chatwoot raw scenario serializers and `{ payload, meta }` lists, hide disabled scenarios from index, and return `204` on delete; custom tools now accept nested `{ custom_tool }`, auto-generate slugs from title, enforce account scope, and return raw tool serializers or `{ payload, meta }` lists. | `go test ./internal/handler/api/v1 -run 'CaptainScenario\|CaptainCustomTool\|CaptainResource' -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; focused fixtures cover nested frontend bodies, account scoping, disabled scenario filtering, raw payloads, custom-tool slug generation, and no local envelopes. | Continue B11.1c with Captain documents, assistant responses, and bulk actions; custom-tool `test` still needs the reference unsaved-tool execution contract and safe network boundary before B11 leaves Review. | | `feat(captain): align document response actions` | Completes B11.1c frontend payload depth for Captain documents, assistant responses, bulk actions, and custom-tool test: document list/create/show/sync/delete now use account-scoped raw serializers and `{ payload, meta }`; assistant responses use nested `{ assistant_response }`, account-scoped raw serializers, filters, edited flag, and `204` delete; bulk actions accept Chatwoot `{ type, ids, fields.status }` for `AssistantResponse` and `AssistantDocument`; custom-tool test accepts unsaved nested `{ custom_tool }` configs and returns raw `{ status, body }` with a fakeable HTTP boundary. | Focused B11.1c handler tests cover document list/create/show/sync/delete, response list/create/show/update/delete, bulk approve/delete/sync/delete shapes, custom-tool test success/error contract, account scoping, sync status, and no local envelopes. Verification passed: focused B11.1c handler tests, Captain/Copilot service tests, Captain repository tests, handler/service package tests, escalated full `go test ./...`, and `git diff --check`. | Continue B11.2 with Copilot persistence, disabled-state payloads, and document/LLM/Meilisearch gates. B11 remains Doing until Copilot and external-provider gates are tracked and tested. | +| `feat(copilot): align thread message payloads` | Completes the B11.2 Copilot thread/message frontend contract: thread create accepts Chatwoot `{ message, assistant_id, conversation_id }`, creates the initial user message plus deterministic assistant fallback when no LLM provider is configured, validates assistant account scope, and returns raw thread payloads. Thread list returns `{ payload }` scoped to current account/user with Chatwoot page size/order. Nested `copilot_messages` list/create returns raw message payloads with embedded thread/user/assistant push data and `204` delete remains available for local compatibility. | `go test ./internal/handler/api/v1 -run 'Copilot' -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; `go test ./internal/repository -run 'Copilot' -count=1`; handler/service/repository package tests; escalated full `go test ./...`; `git diff --check`. Focused handler fixtures cover no local envelopes, account/user isolation, assistant scope, nested message order, and no-LLM assistant fallback persistence. | Continue B11.3 with Captain document sync/indexing, Meilisearch/embedding gates, Copilot tasks/preferences/tool-call/playground depth, streaming/realtime compatibility, and reused frontend smoke coverage. | ## Next Slice Contract -Completed implementation slice: B11.1c now aligns Captain documents, assistant responses, bulk actions, and custom-tool test execution with Chatwoot frontend payloads, building on B11.1a-B11.1b assistant/scenario/tool resources. +Completed implementation slice: B11.2 now aligns Copilot thread and nested message persistence/payloads with the reused Chatwoot frontend, building on B11.1a-B11.1c Captain resource contracts. -Next implementation slice: continue B11.2 Copilot persistence, disabled-state payloads, and safe LLM/document gates. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. +Next implementation slice: continue B11.3 document sync/indexing, Meilisearch/embedding gates, Copilot tasks/preferences/tool-call/playground depth, and streaming/realtime compatibility. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -177,7 +178,8 @@ Next implementation slice: continue B11.2 Copilot persistence, disabled-state pa | N17 | Keep B11.1a Captain assistant resources as current Captain baseline. | `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb`, Captain inboxes controller, assistant Jbuilder views, dashboard Captain assistant/inbox API clients, `config/agents/tools.yml`. | Done by `feat(captain): align assistant resources`; assistant CRUD/tools/inbox binding payloads are account-scoped and frontend-shaped. | | N18 | Keep B11.1b Captain scenario/custom-tool resources as current Captain baseline. | Captain scenarios/custom tools controllers, Jbuilder views, and dashboard clients. | Done by `feat(captain): align scenario and tool payloads`; scenarios and custom tools use nested frontend bodies, account scoping, raw payloads, and Chatwoot list meta. | | N19 | Keep B11.1c Captain document/response/bulk/test resources as current Captain baseline. | Captain documents, assistant responses, bulk actions, custom-tool test controllers, Jbuilder views, and dashboard clients. | Done by `feat(captain): align document response actions`; documents, assistant responses, bulk resource actions, and unsaved custom-tool test use account-scoped Chatwoot payloads with a fakeable HTTP boundary. | -| N20 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. | +| N20 | Keep B11.2 Copilot thread/message resources as current Copilot baseline. | `copilot_threads_controller.rb`, `copilot_messages_controller.rb`, Copilot Jbuilder partials, and dashboard `copilotThreads.js`/`copilotMessages.js` clients. | Done by `feat(copilot): align thread message payloads`; threads/messages persist account/user-scoped Chatwoot payloads and no-LLM fallback assistant messages. | +| N21 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. | Current B2 profile checkpoint: @@ -407,8 +409,8 @@ Upcoming enterprise task boards: | B10 | B10.3 | Align CustomRole permission keys, account-user role resolution, deletion nullification, and authorization failure payloads. | Chatwoot custom role controllers/policies and permission constants. | Permission matrix tests for admin/non-admin/custom-role access. | Done by `feat(custom-roles): align chatwoot permissions` | | B10 | B10.4 | Review remaining InboxLimit/account-limit create-path enforcement outside capacity policies. | Chatwoot enterprise account/inbox limit policies. | Inbox/channel create tests for over-limit behavior and frontend-readable errors. | Done by `feat(inboxes): enforce chatwoot inbox limits` | | B11 | B11.1 | Align Captain assistant CRUD, inbox bindings, responses, documents, scenarios, and custom tools payloads. | Captain controllers/services/frontend clients under `reference/chatwoot`. | Handler/service fixtures for every Captain dashboard client path. | Review; assistant CRUD/tools/inbox binding, scenarios, custom tools, documents, assistant responses, bulk actions, and custom-tool test payloads are landed | -| B11 | B11.2 | Align Copilot threads, messages, tasks, preferences, playground/tool-call behavior, and disabled-state feature gates. | Copilot controllers/services/frontend clients under `reference/chatwoot`. | Copilot handler/service tests for persistence, disabled LLM state, and frontend payloads. | Todo | -| B11 | B11.3 | Add document sync/embedding/LLM job boundaries where external dependencies are required. | Captain/Copilot jobs and document services. | Worker tests or explicit feature-gated fallback tests. | Todo | +| B11 | B11.2 | Align Copilot threads, messages, tasks, preferences, playground/tool-call behavior, and disabled-state feature gates. | Copilot controllers/services/frontend clients under `reference/chatwoot`. | Copilot handler/service tests for persistence, disabled LLM state, and frontend payloads. | Review; thread/message payloads, account/user scoping, assistant scope, and no-LLM fallback are landed; tasks/preferences/tool-call/playground depth remains in B11.3 follow-up | +| B11 | B11.3 | Add document sync/embedding/LLM job boundaries where external dependencies are required and finish remaining Copilot task/preference/tool-call/streaming depth. | Captain/Copilot jobs, document services, Copilot controllers/services/frontend clients. | Worker tests or explicit feature-gated fallback tests plus Copilot task/preference/tool-call fixtures. | Todo | | B12 | B12.1 | Add a repeatable command to run the reused Chatwoot frontend against GoChat. | `reference/chatwoot` frontend boot/auth/API clients. | Smoke command documented and runnable locally. | Todo | | B12 | B12.2 | Cover login, inbox list/settings, conversation list/detail/message send, contact/company views, widget init/message, public CSAT, SLA/CSAT reports, and enterprise admin screens. | Dashboard route usage and frontend stores/API modules. | Smoke report checked into `docs/parity/` with pass/fail gaps. | Todo | @@ -768,8 +770,8 @@ B11 Captain/Copilot breakdown: | B11.1a | Align Captain assistant CRUD, tools, and inbox binding/list/delete payloads used by `assistant.js` and `inboxes.js`. | Assistant controller, inboxes controller, assistant Jbuilder views, `config/agents/tools.yml`, dashboard Captain assistant/inbox clients. | Handler tests for assistant lifecycle, account scoping, tools array, raw inbox binding, list meta, and `204` delete. | Done by `feat(captain): align assistant resources` | | B11.1b | Align Captain scenario and custom-tool CRUD/list payloads. | `scenarios_controller.rb`, `custom_tools_controller.rb`, scenario/custom-tool Jbuilder views, dashboard Captain clients. | Handler fixtures for nested bodies, list/show/create/update/delete, account scoping, disabled scenario filtering, and no local response envelopes. | Done by `feat(captain): align scenario and tool payloads` | | B11.1c | Align Captain documents, assistant responses, bulk actions, and custom-tool test execution. | `documents_controller.rb`, `assistant_responses_controller.rb`, `bulk_actions_controller.rb`, `custom_tools_controller#test`, dashboard Captain clients. | Handler/service fixtures for document list/create/show/sync/delete, response list/show/create/update/delete, bulk-action payloads, test execution errors, account scoping, sync status, and no local response envelopes. | Done by `feat(captain): align document response actions` | -| B11.2 | Implement document sync/indexing gates for Meilisearch or the chosen embedding/search backend without blocking the frontend when LLM config is absent. | Captain document and embedding services. | Tests cover disabled state, failed sync observability, and successful fake backend indexing. | Todo | -| B11.3 | Align Copilot thread/message/task APIs, tool calls, preferences, and streaming fallback. | Copilot controllers/services/frontend clients. | Handler tests cover thread/message/task lifecycle, tool-call persistence, disabled-state payloads, and non-streaming fallback. | Todo | +| B11.2 | Align Copilot thread/message APIs, account/user scoping, assistant scope, frontend payloads, and no-LLM fallback persistence. | `copilot_threads_controller.rb`, `copilot_messages_controller.rb`, Copilot Jbuilder partials, dashboard `copilotThreads.js` and `copilotMessages.js`. | Handler tests cover thread/message create/list/get/delete, nested message ordering, assistant/account isolation, user isolation, and deterministic disabled LLM fallback. | Done by `feat(copilot): align thread message payloads` | +| B11.3 | Implement document sync/indexing gates for Meilisearch or the chosen embedding/search backend, and finish Copilot task/preference/playground/tool-call/streaming depth without blocking the frontend when LLM config is absent. | Captain document/embedding services plus remaining Copilot controllers/services/frontend clients. | Tests cover disabled state, failed sync observability, successful fake backend indexing, Copilot task/preference/tool-call persistence, and non-streaming fallback. | Todo | B11.1a current checkpoint: @@ -787,7 +789,7 @@ B11.1b current checkpoint: - Scenario create/update now accept nested `{ scenario: ... }`, show/update/delete are scoped by account plus assistant, index returns enabled scenarios only with `{ payload, meta }`, and single-resource mutations return the raw scenario serializer with assistant ID/name. - Custom-tool create/update now accept nested `{ custom_tool: ... }`; create auto-generates the Chatwoot-style slug from title when the frontend omits `slug`; list returns `{ payload, meta }`; show/update/delete are account-scoped; single-resource mutations return the raw custom-tool serializer with Unix timestamps and JSON `auth_config`/`param_schema` values. - Focused tests cover nested frontend bodies, account-scope isolation, disabled scenario filtering, custom-tool slug generation, and absence of local `{ success, data }` envelopes. -- B11.1c has now taken over the remaining Captain resource gaps; Copilot persistence and external document/LLM gates remain in B11.2-B11.3. +- B11.1c has now taken over the remaining Captain resource gaps; B11.2 has landed Copilot thread/message persistence, while external document/LLM gates and remaining Copilot task/tool-call depth remain in B11.3. B11.1c current checkpoint: @@ -798,13 +800,22 @@ B11.1c current checkpoint: - Custom-tool `test` now accepts an unsaved nested `{ custom_tool: ... }` config and returns raw `{ status, body }`. The HTTP call boundary is injectable so default tests never open sockets or hit external networks. - Focused tests cover document list/create/show/sync/delete, response list/create/show/update/delete, bulk resource actions, invalid bulk errors, custom-tool test payloads, account-scope isolation, sync status mapping, and no local `{ success, data }` envelopes. +B11.2 current checkpoint: + +- Reference files inspected for this slice: enterprise `copilot_threads_controller.rb`, `copilot_messages_controller.rb`, Copilot thread/message Jbuilder partials and index/create views, dashboard `copilotThreads.js`, `copilotMessages.js`, and the Copilot Vue/store callers. +- Thread create now accepts Chatwoot `{ message, assistant_id, conversation_id }`, rejects blank messages with `422 { error: "Message is required" }`, validates assistant account ownership, stores the thread title from the initial message, creates the initial user message, and creates a deterministic assistant fallback message when no LLM provider is configured. +- Thread list is scoped to current account plus current user, ordered `created_at DESC`, paged at 5 records, and returns `{ payload: [...] }` with raw thread serializers containing `user`, `assistant`, `account_id`, and Unix `created_at`. +- Nested `copilot_messages` list/create are scoped through the current user's thread, return `{ payload }` lists ordered ascending and raw message create payloads, and include embedded `copilot_thread` push data for the reused frontend. +- `GetThread`/`DeleteThread` remain available for local compatibility but now use the same account/user scoping; delete removes thread messages and returns `204`. +- Focused tests cover raw payloads without local `{ success, data }`, assistant/account isolation, user isolation, nested message persistence/order, and the safe no-LLM assistant fallback message. + B11 landing rules: | Area | Landing requirement | Done signal | | --- | --- | --- | | Captain assistant resources | Audit the current `reference/chatwoot` Captain route/controller/frontend client set before code changes, then implement account-scoped CRUD and nested assistant resources with raw frontend-compatible payloads. | Handler tests cover list/show/create/update/delete, inbox binding, response/scenario/document/custom-tool paths, and disabled-state responses. | | Captain document sync | External embedding/LLM work must sit behind fakeable interfaces and config gates; missing provider config must not break the reused frontend. | Tests cover disabled config, fake successful sync, failure metadata, and no unhandled external call in default test mode. | -| Copilot persistence | Threads, messages, tasks, preferences, playground state, and tool-call records must persist enough data for frontend reloads. | Tests cover create/list/show/update flows, account/user scoping, tool-call serialization, and task lifecycle. | +| Copilot persistence | Threads/messages are now persisted and frontend-shaped; tasks, preferences, playground state, and tool-call records must still persist enough data for frontend reloads. | B11.2 tests cover thread/message create/list/get/delete and account/user scoping; B11.3 must add tool-call serialization and task/preference lifecycle tests. | | Streaming fallback | If Chatwoot streams a response but GoChat cannot yet stream safely, return a documented frontend-compatible non-streaming or disabled state rather than a placeholder success. | Tests prove the frontend API path receives a deterministic payload/status. | | Deferred external depth | Model/provider-specific LLM behavior may be feature-gated, but every gate must be visible in this tracker and covered by tests. | B11 stays `Review`, not `Done`, while any external-provider depth remains deferred. | @@ -1196,7 +1207,7 @@ Included checklist: - [x] Custom roles and permission checks. - [x] Agent capacity and assignment limits. - [ ] Assignment policies and auto-assignment compatibility. -- [ ] Captain/Copilot assistant, custom tools, scenarios, documents, responses, and inbox bindings. Captain assistant CRUD/tools/inbox binding, scenario/custom-tool CRUD, documents, assistant responses, bulk actions, and custom-tool test payloads are landed; Copilot and external document/LLM gates remain active. +- [ ] Captain/Copilot assistant, custom tools, scenarios, documents, responses, inbox bindings, and Copilot threads/messages. Captain assistant CRUD/tools/inbox binding, scenario/custom-tool CRUD, documents, assistant responses, bulk actions, custom-tool test payloads, and Copilot thread/message payloads are landed; external document/LLM gates and remaining Copilot task/tool-call depth remain active. - [x] CSAT survey response flow, metrics, filters, and review notes. - [x] Inbox limits and account/inbox usage enforcement. - [x] Automation rules, macros, execution logs, and action side effects. @@ -1215,7 +1226,7 @@ Enterprise tracking table: | P4.3 | Custom roles/permissions | `internal/model/custom_role.go`, `internal/service/custom_role_service.go`, `internal/middleware/role_check.go`, `internal/handler/api/v1/custom_role_handler.go` | Permission arrays, raw admin payloads, administrator gates, account-user resolution, profile permissions, and delete nullification are implemented; keep in Review until frontend smoke covers enterprise settings. | Review | | P4.4 | Agent capacity | `internal/model/agent_capacity_policy.go`, `internal/service/agent_capacity_policy_service.go`, `internal/handler/api/v1/agent_capacity_handler.go`, `internal/autoassignment/*` | First align AgentCapacityPolicy API, serializers, nested users, and `InboxCapacityLimit` data contracts; then enforce capacity in manual/auto assignment. | Review | | P4.5 | Inbox limits | `internal/model/agent_capacity_policy.go` `InboxCapacityLimit`, plus legacy `internal/model/inbox_limit.go`, `internal/service/inbox_limit_service.go`, `internal/repository/inbox_limit_repo.go`, `internal/handler/api/v1/inbox_limit_handler.go` | Chatwoot enterprise nested `agent_capacity_policies/:policy_id/inbox_limits`, assignment-path enforcement, and account-level `usage_limits[:inboxes]` create-path enforcement are covered. | Review | -| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c are aligned; complete Copilot threads/messages/tasks, suggestions, document/LLM gates, and streaming compatibility. | Doing | +| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c and Copilot thread/message payloads through B11.2 are aligned; complete Copilot tasks/preferences/tool-call depth, suggestions, document/LLM gates, and streaming compatibility. | Doing | | P4.7 | CSAT | `internal/csat/*`, `internal/automation/csat_survey_*`, `internal/handler/api/v1/csat_*`, `internal/service/csat_metrics_service.go` | Account-side list/metrics/review-note payloads, public update depth, resolve-triggered generic survey send, and download CSV are aligned; channel-specific template send hooks remain Phase 5 integration work. | Review | | P4.8 | Automation and macros | `internal/automation/*`, `internal/handler/api/v1/automation_rule_handler.go`, `internal/handler/api/v1/macro_handler.go` | Automation-rule CRUD/listener/log/external-action delivery and macro CRUD/display-ID execution parity are implemented; durable delayed actions and deeper macro attachment/file parity remain B9.3/B9.4 follow-ups. | Review | | P4.9 | Assignment policies | `internal/autoassignment/*`, `internal/automation/agent_bot_rule_listener.go` | Match Chatwoot assignment policy behavior and availability/capacity rules. | Doing | @@ -1232,7 +1243,7 @@ Enterprise work package breakdown: | Audit | Audit model parity, mutating action coverage, request metadata, filters/pagination, admin endpoint payloads. | List/admin payload parity is covered by `feat(audit): align chatwoot audit log payloads`; named representative writer coverage is covered by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations`. | Review | | Custom roles | Permission-key parity, account-user role resolution, policy middleware, create/update/delete behavior. | Permission arrays, raw admin payloads, admin gates, RBAC/account-scope/profile resolution, and delete nullification are covered by `feat(custom-roles): align chatwoot permissions`. | Review | | Inbox limits | Account/inbox limit models, create/update enforcement, UI-readable limit responses, admin overrides. | Chatwoot nested capacity-limit API, assignment-path enforcement, and account-level create-limit `402` behavior are covered by B5/B7/B10.4. | Review | -| Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, copilot threads/messages, tasks, streaming/tool-call behavior. | Assistant CRUD/tools/inbox binding fixtures are covered by `feat(captain): align assistant resources`; scenario/custom-tool fixtures are covered by `feat(captain): align scenario and tool payloads`; document/assistant-response/bulk/custom-tool-test fixtures are covered by `feat(captain): align document response actions`; route fixtures, feature gates for external LLM dependencies, Copilot, and frontend smoke screens remain. | Doing | +| Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, copilot threads/messages, tasks, streaming/tool-call behavior. | Assistant CRUD/tools/inbox binding fixtures are covered by `feat(captain): align assistant resources`; scenario/custom-tool fixtures are covered by `feat(captain): align scenario and tool payloads`; document/assistant-response/bulk/custom-tool-test fixtures are covered by `feat(captain): align document response actions`; Copilot thread/message fixtures are covered by `feat(copilot): align thread message payloads`; feature gates for external LLM dependencies, remaining Copilot tasks/tool-calls, and frontend smoke screens remain. | Doing | Enterprise acceptance gates: @@ -1470,3 +1481,4 @@ Verification milestone gates: - 2026-06-05: B11.1a Captain assistant checkpoint prepared as `feat(captain): align assistant resources`; assistant CRUD now returns Chatwoot raw assistant payloads and list meta, nested assistant request bodies are accepted, account-scoped get/update/delete prevents cross-account access, tools returns the raw built-in tool array, and assistant inbox create/list/delete use raw inbox/list/204 contracts. Focused CaptainAssistant handler/service tests passed, route dump/parity regenerated with unchanged `TOTAL: 830`, and `git diff --check` passed. Next slice is B11.1b Captain documents/scenarios/responses/custom tools. - 2026-06-05: B11.1b Captain scenario/custom-tool checkpoint prepared as `feat(captain): align scenario and tool payloads`; scenarios now accept nested frontend bodies, enforce account/assistant scope, list only enabled scenarios, and return raw scenario/list/204 payloads. Custom tools now accept nested frontend bodies, auto-generate slugs, enforce account scope, and return raw tool/list/204 payloads. Focused CaptainScenario/CaptainCustomTool/CaptainResource tests plus Captain/Copilot focused handler/service tests passed. Next slice is B11.1c documents, assistant responses, bulk actions, and custom-tool test execution. - 2026-06-05: B11.1c Captain document/response/action checkpoint prepared as `feat(captain): align document response actions`; documents now return Chatwoot raw/list payloads with account scope and `202` sync marking, assistant responses now support nested bodies, filters, raw serializers, edited tracking, and `204` delete, bulk actions now accept Chatwoot `AssistantResponse`/`AssistantDocument` resource payloads, and custom-tool test now executes unsaved configs through a fakeable HTTP boundary returning `{ status, body }`. Focused B11.1c handler tests, Captain/Copilot service tests, Captain repository tests, handler/service package tests, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.2 Copilot persistence and safe LLM/document gates. +- 2026-06-05: B11.2 Copilot thread/message checkpoint prepared as `feat(copilot): align thread message payloads`; Copilot threads now accept Chatwoot `{ message, assistant_id, conversation_id }`, validate account-scoped assistants, create initial user plus safe no-LLM assistant messages, and return raw thread serializers. Nested Copilot messages now list/create raw message payloads with embedded thread push data, current account/user scoping, ascending message order, and no local envelopes. Focused Copilot handler/service/repository tests, Captain/Copilot handler/service tests, handler/service/repository package tests, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.3 document sync/indexing, Meilisearch/embedding gates, Copilot tasks/preferences/tool-call depth, and streaming/realtime compatibility. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 8f9fc6ad..d6371cdf 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -563,7 +563,7 @@ func Bootstrap(env string) (*App, error) { captainDocumentService := service.NewCaptainDocumentService(captainDocumentRepo, llmProvider, captainAssistantRepo) captainScenarioService := service.NewCaptainScenarioService(captainScenarioRepo, captainAssistantRepo) captainCustomToolService := service.NewCaptainCustomToolService(captainCustomToolRepo) - copilotService := service.NewCopilotService(copilotThreadRepo, copilotMessageRepo, copilotSuggestionRepo, llmProvider) + copilotService := service.NewCopilotService(copilotThreadRepo, copilotMessageRepo, copilotSuggestionRepo, llmProvider, captainAssistantRepo) copilotContextService := service.NewCopilotContextService(messageRepo, conversationRepo, contactRepo, llmProvider) captainTaskService := service.NewCaptainTaskService(captainAssistantRepo, captainAssistantResponseRepo, captainCustomToolRepo, conversationRepo, messageRepo, llmProvider, copilotContextService) conversationInsightService := service.NewConversationInsightService(conversationRepo, messageRepo, captainAssistantRepo, llmProvider) diff --git a/internal/handler/api/v1/copilot_handler.go b/internal/handler/api/v1/copilot_handler.go index f3019dc3..d8a01626 100644 --- a/internal/handler/api/v1/copilot_handler.go +++ b/internal/handler/api/v1/copilot_handler.go @@ -3,14 +3,15 @@ package v1 import ( "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" - "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" -pkgvalidator "github.com/gochat/gochat/pkg/validator" ) + // CopilotHandler handles Copilot REST API endpoints. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/copilot_threads_controller.rb type CopilotHandler struct { @@ -25,17 +26,13 @@ func NewCopilotHandler(svc *service.CopilotService) *CopilotHandler { // CreateThread creates a new copilot thread. // POST /api/v1/accounts/:account_id/copilot_threads func (h *CopilotHandler) CreateThread(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - - // Extract userID from auth context header (X-User-ID). - // When auth middleware is wired, this will come from c.Get("user_id"). - userIDStr := c.GetHeader("X-User-ID") - userID, err := strconv.ParseUint(userIDStr, 10, 64) - if err != nil || userID == 0 { + userID := getUserID(c) + if userID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") return } @@ -46,73 +43,90 @@ func (h *CopilotHandler) CreateThread(c *gin.Context) { return } - thread, err := h.svc.CreateThread(c.Request.Context(), uint(accountID), uint(userID), &req) + thread, err := h.svc.CreateThread(c.Request.Context(), accountID, userID, &req) if err != nil { applogger.L().Errorf("CreateThread: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create thread") + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } - response.Created(c, thread) + c.JSON(http.StatusOK, copilotThreadPayload(thread)) } // GetThread retrieves a copilot thread by ID. // GET /api/v1/accounts/:account_id/copilot_threads/:id func (h *CopilotHandler) GetThread(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + userID := getUserID(c) + if userID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") + return + } + id, err := parseUintAnyParam(c, "copilot_thread_id", "thread_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } - thread, err := h.svc.GetThread(c.Request.Context(), uint(id)) + thread, err := h.svc.GetThread(c.Request.Context(), accountID, userID, id) if err != nil { applogger.L().Errorf("GetThread: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "thread not found") return } - response.OK(c, thread) + c.JSON(http.StatusOK, copilotThreadPayload(thread)) } // ListThreads retrieves copilot threads for a user. // GET /api/v1/accounts/:account_id/copilot_threads func (h *CopilotHandler) ListThreads(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - - userIDStr := c.GetHeader("X-User-ID") - userID, err := strconv.ParseUint(userIDStr, 10, 64) - if err != nil || userID == 0 { + userID := getUserID(c) + if userID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") return } - p := pagination.Parse(c) - threads, count, err := h.svc.ListThreads(c.Request.Context(), uint(accountID), uint(userID), p.Offset, p.PerPage) + page, _ := parseIntQueryDefault(c, "page", 1) + threads, _, err := h.svc.ListThreads(c.Request.Context(), accountID, userID, (page-1)*5, 5) if err != nil { applogger.L().Errorf("ListThreads: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list threads") return } - response.OKWithMeta(c, threads, p.Page, p.PerPage, count) + payload := make([]gin.H, 0, len(threads)) + for i := range threads { + payload = append(payload, copilotThreadPayload(&threads[i])) + } + c.JSON(http.StatusOK, gin.H{"payload": payload}) } // SendMessage sends a message in a copilot thread and generates an assistant reply. // POST /api/v1/accounts/:account_id/copilot_threads/:id/messages func (h *CopilotHandler) SendMessage(c *gin.Context) { - threadID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid thread id") + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + userID := getUserID(c) + if userID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") + return + } + threadID, err := parseUintAnyParam(c, "copilot_thread_id", "thread_id", "id") if err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid thread id") return } @@ -122,21 +136,20 @@ func (h *CopilotHandler) SendMessage(c *gin.Context) { return } - result, err := h.svc.SendMessage(c.Request.Context(), uint(threadID), uint(accountID), &req) + result, err := h.svc.SendMessage(c.Request.Context(), accountID, userID, threadID, &req) if err != nil { applogger.L().Errorf("SendMessage: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to send message") + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } - - response.Created(c, result) + c.JSON(http.StatusOK, copilotMessagePayload(result.UserMessage)) } // GetSuggestedReplies generates reply suggestions for a conversation. // GET /api/v1/accounts/:account_id/conversations/:conversation_id/suggested_replies func (h *CopilotHandler) GetSuggestedReplies(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -149,7 +162,7 @@ func (h *CopilotHandler) GetSuggestedReplies(c *gin.Context) { return } - result, err := h.svc.GetSuggestedReplies(c.Request.Context(), uint(accountID), conversationContext) + result, err := h.svc.GetSuggestedReplies(c.Request.Context(), accountID, conversationContext) if err != nil { applogger.L().Errorf("GetSuggestedReplies: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate suggested replies") @@ -162,8 +175,8 @@ func (h *CopilotHandler) GetSuggestedReplies(c *gin.Context) { // SummarizeConversation generates a summary of a conversation. // GET /api/v1/accounts/:account_id/conversations/:conversation_id/summary func (h *CopilotHandler) SummarizeConversation(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -174,7 +187,7 @@ func (h *CopilotHandler) SummarizeConversation(c *gin.Context) { return } - result, err := h.svc.SummarizeConversation(c.Request.Context(), uint(accountID), conversationContext) + result, err := h.svc.SummarizeConversation(c.Request.Context(), accountID, conversationContext) if err != nil { applogger.L().Errorf("SummarizeConversation: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to summarize conversation") @@ -187,15 +200,25 @@ func (h *CopilotHandler) SummarizeConversation(c *gin.Context) { // DeleteThread deletes a copilot thread. // DELETE /api/v1/accounts/:account_id/copilot_threads/:id func (h *CopilotHandler) DeleteThread(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + userID := getUserID(c) + if userID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") + return + } + id, err := parseUintAnyParam(c, "copilot_thread_id", "thread_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } - if err := h.svc.DeleteThread(c.Request.Context(), uint(id)); err != nil { + if err := h.svc.DeleteThread(c.Request.Context(), accountID, userID, id); err != nil { applogger.L().Errorf("DeleteThread: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete thread") + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "thread not found") return } @@ -205,8 +228,8 @@ func (h *CopilotHandler) DeleteThread(c *gin.Context) { // TranslateMessage translates a message to a target language. // POST /api/v1/accounts/:account_id/copilot/translate func (h *CopilotHandler) TranslateMessage(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -217,7 +240,7 @@ func (h *CopilotHandler) TranslateMessage(c *gin.Context) { return } - result, err := h.svc.TranslateMessage(c.Request.Context(), uint(accountID), &req) + result, err := h.svc.TranslateMessage(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("TranslateMessage: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to translate message") @@ -234,21 +257,41 @@ func (h *CopilotHandler) TranslateMessage(c *gin.Context) { // ListSuggestionMessages lists copilot suggestion messages for a conversation. // GET /api/v1/accounts/:account_id/copilot_messages func (h *CopilotHandler) ListSuggestionMessages(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } + if threadID, err := parseUintAnyParam(c, "copilot_thread_id", "thread_id"); err == nil && threadID != 0 { + userID := getUserID(c) + if userID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid or missing user_id") + return + } + page, _ := parseIntQueryDefault(c, "page", 1) + messages, _, err := h.svc.ListThreadMessages(c.Request.Context(), accountID, userID, threadID, page, 1000) + if err != nil { + applogger.L().Errorf("ListCopilotMessages: %v", err) + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "thread not found") + return + } + payload := make([]gin.H, 0, len(messages)) + for i := range messages { + payload = append(payload, copilotMessagePayload(&messages[i])) + } + c.JSON(http.StatusOK, gin.H{"payload": payload}) + return + } conversationIDStr := c.Query("conversation_id") - conversationID, err := strconv.ParseUint(conversationIDStr, 10, 64) + conversationID, err := parseUintString(conversationIDStr) if err != nil || conversationID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "conversation_id is required") return } - p := pagination.Parse(c) - result, err := h.svc.GetCopilotSuggestions(c.Request.Context(), uint(accountID), uint(conversationID), p.Page, p.PerPage) + page, _ := parseIntQueryDefault(c, "page", 1) + result, err := h.svc.GetCopilotSuggestions(c.Request.Context(), accountID, conversationID, page, 25) if err != nil { applogger.L().Errorf("ListSuggestionMessages: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list suggestion messages") @@ -261,8 +304,8 @@ func (h *CopilotHandler) ListSuggestionMessages(c *gin.Context) { // CreateSuggestionMessage creates a copilot suggestion message. // POST /api/v1/accounts/:account_id/copilot_messages func (h *CopilotHandler) CreateSuggestionMessage(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -272,12 +315,12 @@ func (h *CopilotHandler) CreateSuggestionMessage(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - if err := pkgvalidator.ValidateStruct(&req); err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + if req.ConversationID == 0 || strings.TrimSpace(req.Content) == "" { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_id and content are required") return } - msg, err := h.svc.CreateCopilotSuggestion(c.Request.Context(), uint(accountID), &req) + msg, err := h.svc.CreateCopilotSuggestion(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("CreateSuggestionMessage: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create suggestion message") @@ -285,4 +328,66 @@ func (h *CopilotHandler) CreateSuggestionMessage(c *gin.Context) { } response.Created(c, msg) -} \ No newline at end of file +} + +func parseUintString(raw string) (uint, error) { + if raw == "" { + return 0, http.ErrMissingFile + } + n, err := strconv.ParseUint(raw, 10, 32) + return uint(n), err +} + +func copilotThreadPayload(thread *model.CopilotThread) gin.H { + return gin.H{ + "id": thread.ID, + "title": thread.Title, + "created_at": thread.CreatedAt.Unix(), + "user": copilotUserPayload(&thread.User, thread.UserID, thread.AccountID), + "assistant": copilotAssistantPushPayload(&thread.Assistant, thread.AssistantID), + "account_id": thread.AccountID, + } +} + +func copilotMessagePayload(message *model.CopilotMessage) gin.H { + return gin.H{ + "id": message.ID, + "message": rawJSONValue(message.Message), + "message_type": message.MessageType, + "created_at": message.CreatedAt.Unix(), + "copilot_thread": copilotThreadPayload(&message.CopilotThread), + "account_id": message.AccountID, + } +} + +func copilotUserPayload(user *model.User, fallbackID, accountID uint) gin.H { + if user == nil || user.ID == 0 { + return gin.H{"id": fallbackID, "account_id": accountID, "type": "user"} + } + return gin.H{ + "id": user.ID, + "name": user.Name, + "available_name": nonEmpty(user.DisplayName, user.Name), + "avatar_url": user.AvatarURL, + "type": "user", + "availability_status": availabilityStatus(user.Available), + "thumbnail": user.AvatarURL, + } +} + +func copilotAssistantPushPayload(assistant *model.CaptainAssistant, fallbackID *uint) gin.H { + if assistant == nil || assistant.ID == 0 { + if fallbackID == nil { + return gin.H{} + } + return gin.H{"id": *fallbackID, "type": "captain_assistant"} + } + return gin.H{ + "id": assistant.ID, + "name": assistant.Name, + "avatar_url": "", + "description": assistant.Description, + "created_at": assistant.CreatedAt.Unix(), + "type": "captain_assistant", + } +} diff --git a/internal/handler/api/v1/copilot_thread_handler_test.go b/internal/handler/api/v1/copilot_thread_handler_test.go index f0717570..fa49856d 100644 --- a/internal/handler/api/v1/copilot_thread_handler_test.go +++ b/internal/handler/api/v1/copilot_thread_handler_test.go @@ -2,556 +2,271 @@ package v1 import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" + "time" "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" + "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ) -// --- Copilot Thread Handler Test Suite --- -// 测试 CopilotThread 的 CRUD + LLM-powered 接口 -// 使用真实 SQLite 内存数据库 + 真实 repo + 真实 service + mock LLM -// -// 路由参数冲突说明: -// 实际路由 accounts/:id/copilot_threads/:id 中两个 :id 同名, -// Gin 的 c.Param("id") 只返回第一个匹配(account_id), -// 导致 GetThread/DeleteThread/SendMessage handler 无法获取 thread_id —— 这是已知的路由 bug。 -// 测试中使用三个独立的 gin.Engine 来分别验证不同上下文: -// - accountRouter: c.Param("id") = account_id (用于 CreateThread/ListThread/SuggestedReplies/Summarize/Translate) -// - threadRouter: c.Param("id") = thread_id (用于 GetThread/DeleteThread) -// - messageRouter: c.Param("id") = thread_id (用于 SendMessage — handler 两次调用 c.Param("id"), -// 第一次获取 threadID,第二次获取 accountID,两者返回同一值) - -// mockThreadLLMProvider 用于测试中模拟 LLM 调用 -type mockThreadLLMProvider struct{} - -func (m *mockThreadLLMProvider) ChatCompletion(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) { - return &llm.ChatResponse{ - Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: "mock assistant response"}}}, - }, nil +type copilotParityFixture struct { + db *gorm.DB + router *gin.Engine + otherUserRouter *gin.Engine + account *model.Account + otherAccount *model.Account + user *model.User + otherUser *model.User + assistant *model.CaptainAssistant + otherAssistant *model.CaptainAssistant } -func (m *mockThreadLLMProvider) CreateEmbedding(_ context.Context, _ llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { - return &llm.EmbeddingResponse{}, nil -} - -func (m *mockThreadLLMProvider) ChatCompletionStream(_ context.Context, _ llm.ChatRequest, _ func(llm.StreamChunk) error) error { - return nil -} - -type CopilotThreadHandlerTestSuite struct { - suite.Suite - accountRouter *gin.Engine // :id = account_id - threadRouter *gin.Engine // :id = thread_id - messageRouter *gin.Engine // :id = thread_id (SendMessage) - handler *CopilotHandler - db *gorm.DB - account *model.Account -} - -func (s *CopilotThreadHandlerTestSuite) SetupSuite() { +func newCopilotParityFixture(t *testing.T) *copilotParityFixture { + t.Helper() gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - s.Require().NoError(err) - s.db = db - - // 自动迁移所需模型 - err = db.AutoMigrate( + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( &model.Account{}, + &model.User{}, + &model.CaptainAssistant{}, &model.CopilotThread{}, &model.CopilotMessage{}, &model.CopilotSuggestionMessage{}, - ) - s.Require().NoError(err) + )) - // 创建测试账户 - account := &model.Account{Name: "CopilotThreadTestOrg", Locale: "en", Active: true} - s.Require().NoError(db.Create(account).Error) - s.account = account + account := &model.Account{Name: "Copilot Org", Locale: "en", Active: true} + otherAccount := &model.Account{Name: "Other Org", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + require.NoError(t, db.Create(otherAccount).Error) + + user := &model.User{AccountID: account.ID, Name: "Agent One", DisplayName: "Agent", Email: "agent@example.com", Password: "secret", Active: true, Available: true} + otherUser := &model.User{AccountID: account.ID, Name: "Agent Two", Email: "agent2@example.com", Password: "secret", Active: true} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(otherUser).Error) + + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Helper", Description: "Primary assistant", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive} + otherAssistant := &model.CaptainAssistant{AccountID: otherAccount.ID, Name: "Other", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive} + require.NoError(t, db.Create(assistant).Error) + require.NoError(t, db.Create(otherAssistant).Error) - // 创建 repo + service + handler threadRepo := repository.NewCopilotThreadRepo(db) messageRepo := repository.NewCopilotMessageRepo(db) suggestionRepo := repository.NewCopilotSuggestionRepo(db) - mockProvider := &mockThreadLLMProvider{} - svc := service.NewCopilotService(threadRepo, messageRepo, suggestionRepo, mockProvider) - s.handler = NewCopilotHandler(svc) + assistantRepo := repository.NewCaptainAssistantRepo(db) + handler := NewCopilotHandler(service.NewCopilotService(threadRepo, messageRepo, suggestionRepo, nil, assistantRepo)) - // accountRouter: :id 作为 account_id,用于 CreateThread/ListThread/SuggestedReplies/Summarize/Translate - // 路径不含嵌套 :id,所以 c.Param("id") 总是返回 account_id - s.accountRouter = gin.New() - s.accountRouter.RedirectTrailingSlash = false - accGroup := s.accountRouter.Group("/api/v1/accounts/:id") - { - accGroup.POST("/copilot_threads/", s.handler.CreateThread) - accGroup.GET("/copilot_threads/", s.handler.ListThreads) - accGroup.GET("/suggested_replies", s.handler.GetSuggestedReplies) - accGroup.GET("/summary", s.handler.SummarizeConversation) - accGroup.POST("/copilot/translate", s.handler.TranslateMessage) - } - - // threadRouter: :id 作为 thread_id,用于 GetThread/DeleteThread - // 路径不含 account 嵌套,所以 c.Param("id") 总是返回 thread_id - // (实际路由是 /accounts/:account_id/copilot_threads/:id, - // 但 handler 读 c.Param("id") 而不是 c.Param("account_id")) - s.threadRouter = gin.New() - s.threadRouter.RedirectTrailingSlash = false - { - s.threadRouter.GET("/api/v1/copilot_threads/:id", s.handler.GetThread) - s.threadRouter.DELETE("/api/v1/copilot_threads/:id", s.handler.DeleteThread) - } - - // messageRouter: :id 作为 thread_id,用于 SendMessage - // SendMessage handler 两次调用 c.Param("id") (threadID 和 accountID) - // 都返回同一值(thread_id),这是已知的 SendMessage bug - s.messageRouter = gin.New() - s.messageRouter.RedirectTrailingSlash = false - { - s.messageRouter.POST("/api/v1/copilot_threads/:id/messages", s.handler.SendMessage) + fixture := &copilotParityFixture{ + db: db, + account: account, + otherAccount: otherAccount, + user: user, + otherUser: otherUser, + assistant: assistant, + otherAssistant: otherAssistant, } + fixture.router = copilotRouterForUser(handler, user.ID) + fixture.otherUserRouter = copilotRouterForUser(handler, otherUser.ID) + t.Cleanup(func() { + sqlDB, dbErr := db.DB() + require.NoError(t, dbErr) + require.NoError(t, sqlDB.Close()) + }) + return fixture } -func (s *CopilotThreadHandlerTestSuite) TearDownSuite() { - sqlDB, err := s.db.DB() - s.Require().NoError(err) - sqlDB.Close() +func copilotRouterForUser(handler *CopilotHandler, userID uint) *gin.Engine { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("user_id", userID) + c.Next() + }) + accounts := router.Group("/api/v1/accounts/:account_id") + captain := accounts.Group("/captain") + threads := captain.Group("/copilot_threads") + threads.GET("/", handler.ListThreads) + threads.POST("/", handler.CreateThread) + threads.GET("/:thread_id", handler.GetThread) + threads.DELETE("/:thread_id", handler.DeleteThread) + messages := threads.Group("/:thread_id/copilot_messages") + messages.GET("/", handler.ListSuggestionMessages) + messages.POST("/", handler.SendMessage) + return router } -func (s *CopilotThreadHandlerTestSuite) SetupTest() { - // 每个测试前清空表,避免数据交叉 - s.db.Exec("DELETE FROM copilot_messages") - s.db.Exec("DELETE FROM copilot_threads") - s.db.Exec("DELETE FROM sqlite_sequence WHERE name='copilot_threads'") - s.db.Exec("DELETE FROM sqlite_sequence WHERE name='copilot_messages'") -} - -// helper: 获取 account 路径前缀 (for accountRouter) -func (s *CopilotThreadHandlerTestSuite) accountPath() string { - return "/api/v1/accounts/" + strconv.FormatUint(uint64(s.account.ID), 10) -} - -// helper: 获取 thread 路径前缀 (for threadRouter/messageRouter) -func (s *CopilotThreadHandlerTestSuite) threadPath(threadID string) string { - return "/api/v1/copilot_threads/" + threadID -} - -// helper: 向 accountRouter 发送请求 -func (s *CopilotThreadHandlerTestSuite) makeAccountRequest(method, path string, body interface{}, headers map[string]string) *httptest.ResponseRecorder { - var bodyBytes []byte +func (f *copilotParityFixture) request(router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { + var raw []byte if body != nil { - bodyBytes, _ = json.Marshal(body) + raw, _ = json.Marshal(body) } - w := httptest.NewRecorder() - req, _ := http.NewRequest(method, path, bytes.NewReader(bodyBytes)) + recorder := httptest.NewRecorder() + req, _ := http.NewRequest(method, path, bytes.NewReader(raw)) if body != nil { req.Header.Set("Content-Type", "application/json") } - for k, v := range headers { - req.Header.Set(k, v) - } - s.accountRouter.ServeHTTP(w, req) - return w + router.ServeHTTP(recorder, req) + return recorder } -// helper: 向 threadRouter 发送请求 -func (s *CopilotThreadHandlerTestSuite) makeThreadRequest(method, path string, body interface{}) *httptest.ResponseRecorder { - var bodyBytes []byte - if body != nil { - bodyBytes, _ = json.Marshal(body) - } - w := httptest.NewRecorder() - req, _ := http.NewRequest(method, path, bytes.NewReader(bodyBytes)) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - s.threadRouter.ServeHTTP(w, req) - return w +func (f *copilotParityFixture) captainPath(path string) string { + return "/api/v1/accounts/" + uintString(f.account.ID) + "/captain" + path } -// helper: 向 messageRouter 发送请求 -func (s *CopilotThreadHandlerTestSuite) makeMessageRequest(method, path string, body interface{}) *httptest.ResponseRecorder { - var bodyBytes []byte - if body != nil { - bodyBytes, _ = json.Marshal(body) - } - w := httptest.NewRecorder() - req, _ := http.NewRequest(method, path, bytes.NewReader(bodyBytes)) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - s.messageRouter.ServeHTTP(w, req) - return w -} - -// helper: 创建线程并返回其 ID (字符串) -func (s *CopilotThreadHandlerTestSuite) createThreadAndGetID(title string) string { - body := map[string]interface{}{ - "title": title, - } - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot_threads/", body, map[string]string{ - "X-User-ID": "1", +func (f *copilotParityFixture) createThread(t *testing.T, message string) map[string]any { + t.Helper() + w := f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{ + "message": message, + "assistant_id": f.assistant.ID, + "conversation_id": 123, }) - s.Require().Equal(http.StatusCreated, w.Code) - - var resp map[string]interface{} - s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - data := resp["data"].(map[string]interface{}) - return strconv.FormatFloat(data["id"].(float64), 'f', -1, 64) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + return decodeMap(t, w) } -// ========== 创建线程测试 ========== +func decodeMap(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any { + t.Helper() + var payload map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) + return payload +} -func (s *CopilotThreadHandlerTestSuite) TestCreateThread_成功创建线程() { - body := map[string]interface{}{ - "title": "测试线程", - } - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot_threads/", body, map[string]string{ - "X-User-ID": "1", +func uintString(id uint) string { + return strconv.FormatUint(uint64(id), 10) +} + +func TestCopilotThreadCreateReturnsChatwootPayload(t *testing.T) { + f := newCopilotParityFixture(t) + + payload := f.createThread(t, "Need help") + require.Nil(t, payload["success"]) + require.Equal(t, "Need help", payload["title"]) + require.Equal(t, float64(f.account.ID), payload["account_id"]) + + user := payload["user"].(map[string]any) + require.Equal(t, float64(f.user.ID), user["id"]) + require.Equal(t, "user", user["type"]) + require.Equal(t, "online", user["availability_status"]) + + assistant := payload["assistant"].(map[string]any) + require.Equal(t, float64(f.assistant.ID), assistant["id"]) + require.Equal(t, "captain_assistant", assistant["type"]) + + var count int64 + require.NoError(t, f.db.Model(&model.CopilotMessage{}).Count(&count).Error) + require.Equal(t, int64(2), count) +} + +func TestCopilotThreadCreateValidationAndAssistantScope(t *testing.T) { + f := newCopilotParityFixture(t) + + w := f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"assistant_id": f.assistant.ID}) + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + require.Equal(t, "Message is required", decodeMap(t, w)["error"]) + + w = f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"message": "hello"}) + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + require.Equal(t, "assistant_id is required", decodeMap(t, w)["error"]) + + w = f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"message": "hello", "assistant_id": f.otherAssistant.ID}) + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + + var count int64 + require.NoError(t, f.db.Model(&model.CopilotThread{}).Count(&count).Error) + require.Equal(t, int64(0), count) +} + +func TestCopilotThreadListIsUserScopedAndOrdered(t *testing.T) { + f := newCopilotParityFixture(t) + first := f.createThread(t, "First") + second := f.createThread(t, "Second") + firstID := uint(first["id"].(float64)) + secondID := uint(second["id"].(float64)) + require.NoError(t, f.db.Model(&model.CopilotThread{}).Where("id = ?", firstID).Update("created_at", time.Now().Add(-time.Hour)).Error) + require.NoError(t, f.db.Model(&model.CopilotThread{}).Where("id = ?", secondID).Update("created_at", time.Now()).Error) + + w := f.request(f.otherUserRouter, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{ + "message": "Other user", + "assistant_id": f.assistant.ID, }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - assert.Equal(s.T(), http.StatusCreated, w.Code) - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - - data := resp["data"].(map[string]interface{}) - assert.Equal(s.T(), "测试线程", data["title"]) - assert.Equal(s.T(), float64(s.account.ID), data["account_id"]) - assert.Equal(s.T(), float64(1), data["user_id"]) + w = f.request(f.router, http.MethodGet, f.captainPath("/copilot_threads/?page=1"), nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + payload := decodeMap(t, w)["payload"].([]any) + require.Len(t, payload, 2) + require.Equal(t, second["id"], payload[0].(map[string]any)["id"]) + require.Equal(t, first["id"], payload[1].(map[string]any)["id"]) } -func (s *CopilotThreadHandlerTestSuite) TestCreateThread_带assistantID创建线程() { - assistantID := uint(5) - body := map[string]interface{}{ - "title": "带assistant的线程", - "assistant_id": float64(assistantID), - } - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot_threads/", body, map[string]string{ - "X-User-ID": "2", - }) +func TestCopilotThreadMessagesListAndCreateUseNestedPayloads(t *testing.T) { + f := newCopilotParityFixture(t) + thread := f.createThread(t, "Need help") + threadID := uintString(uint(thread["id"].(float64))) + path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/") - assert.Equal(s.T(), http.StatusCreated, w.Code) + w := f.request(f.router, http.MethodGet, path, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + messages := decodeMap(t, w)["payload"].([]any) + require.Len(t, messages, 2) + require.Equal(t, "user", messages[0].(map[string]any)["message_type"]) + require.Equal(t, "assistant", messages[1].(map[string]any)["message_type"]) + require.NotNil(t, messages[0].(map[string]any)["copilot_thread"]) - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - data := resp["data"].(map[string]interface{}) - assert.Equal(s.T(), "带assistant的线程", data["title"]) - assert.Equal(s.T(), float64(assistantID), data["assistant_id"]) + w = f.request(f.router, http.MethodPost, path, map[string]any{"message": "Follow up", "conversation_id": 123}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + created := decodeMap(t, w) + require.Nil(t, created["success"]) + require.Equal(t, "user", created["message_type"]) + require.Equal(t, "Follow up", created["message"].(map[string]any)["content"]) + + w = f.request(f.router, http.MethodGet, path, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + messages = decodeMap(t, w)["payload"].([]any) + require.Len(t, messages, 4) } -func (s *CopilotThreadHandlerTestSuite) TestCreateThread_缺少title不会返回400() { - // ShouldBindJSON 不验证 validate 标签,缺少 title 时会创建空 title 的线程 - body := map[string]interface{}{} - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot_threads/", body, map[string]string{ - "X-User-ID": "1", - }) - // ShouldBindJSON 解析成功 → handler 继续执行 → 返回 201 - assert.Equal(s.T(), http.StatusCreated, w.Code) +func TestCopilotThreadMessagesAreAccountAndUserScoped(t *testing.T) { + f := newCopilotParityFixture(t) + thread := f.createThread(t, "Private thread") + threadID := uintString(uint(thread["id"].(float64))) + path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/") + + w := f.request(f.otherUserRouter, http.MethodGet, path, nil) + require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + + otherAccountPath := "/api/v1/accounts/" + uintString(f.otherAccount.ID) + "/captain/copilot_threads/" + threadID + "/copilot_messages/" + w = f.request(f.router, http.MethodGet, otherAccountPath, nil) + require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) } -func (s *CopilotThreadHandlerTestSuite) TestCreateThread_缺少XUserID头返回400() { - body := map[string]interface{}{ - "title": "测试线程", - } - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot_threads/", body, map[string]string{}) +func TestCopilotThreadGetAndDeleteAreScoped(t *testing.T) { + f := newCopilotParityFixture(t) + thread := f.createThread(t, "Delete me") + threadID := uintString(uint(thread["id"].(float64))) + path := f.captainPath("/copilot_threads/" + threadID) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) + w := f.request(f.router, http.MethodGet, path, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, "Delete me", decodeMap(t, w)["title"]) + + w = f.request(f.otherUserRouter, http.MethodDelete, path, nil) + require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + + w = f.request(f.router, http.MethodDelete, path, nil) + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + + w = f.request(f.router, http.MethodGet, path, nil) + require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) } - -func (s *CopilotThreadHandlerTestSuite) TestCreateThread_无效accountID返回400() { - body := map[string]interface{}{ - "title": "测试线程", - } - w := s.makeAccountRequest("POST", "/api/v1/accounts/invalid/copilot_threads/", body, map[string]string{ - "X-User-ID": "1", - }) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestCreateThread_无效JSON返回400() { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", s.accountPath()+"/copilot_threads/", bytes.NewReader([]byte("{invalid}"))) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-User-ID", "1") - s.accountRouter.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -// ========== 获取线程测试 ========== - -func (s *CopilotThreadHandlerTestSuite) TestGetThread_成功获取线程() { - threadID := s.createThreadAndGetID("获取测试线程") - - // 使用 threadRouter (c.Param("id") = thread_id) - w := s.makeThreadRequest("GET", s.threadPath(threadID), nil) - assert.Equal(s.T(), http.StatusOK, w.Code) - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - - data := resp["data"].(map[string]interface{}) - assert.Equal(s.T(), "获取测试线程", data["title"]) -} - -func (s *CopilotThreadHandlerTestSuite) TestGetThread_无效ID返回400() { - // 使用 threadRouter,传入 "invalid" 作为 thread_id - w := s.makeThreadRequest("GET", s.threadPath("invalid"), nil) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestGetThread_不存在的ID返回404() { - w := s.makeThreadRequest("GET", s.threadPath("99999"), nil) - assert.Equal(s.T(), http.StatusNotFound, w.Code) -} - -// ========== 列出线程测试 ========== - -func (s *CopilotThreadHandlerTestSuite) TestListThreads_成功列出线程() { - // 创建3个线程 - for i := 0; i < 3; i++ { - body := map[string]interface{}{ - "title": "线程" + strconv.Itoa(i), - } - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot_threads/", body, map[string]string{ - "X-User-ID": "1", - }) - s.Require().Equal(http.StatusCreated, w.Code) - } - - // 列出线程 — 使用 accountRouter - w := s.makeAccountRequest("GET", s.accountPath()+"/copilot_threads/?page=1&per_page=10", nil, map[string]string{ - "X-User-ID": "1", - }) - assert.Equal(s.T(), http.StatusOK, w.Code) - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - - data := resp["data"].([]interface{}) - assert.GreaterOrEqual(s.T(), len(data), 3) - - meta := resp["meta"].(map[string]interface{}) - assert.Equal(s.T(), float64(1), meta["page"]) - assert.Equal(s.T(), float64(10), meta["per_page"]) -} - -func (s *CopilotThreadHandlerTestSuite) TestListThreads_缺少XUserID头返回400() { - w := s.makeAccountRequest("GET", s.accountPath()+"/copilot_threads/", nil, map[string]string{}) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestListThreads_无效accountID返回400() { - w := s.makeAccountRequest("GET", "/api/v1/accounts/invalid/copilot_threads/", nil, map[string]string{ - "X-User-ID": "1", - }) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -// ========== 删除线程测试 ========== - -func (s *CopilotThreadHandlerTestSuite) TestDeleteThread_成功删除线程() { - threadID := s.createThreadAndGetID("删除测试线程") - - // 使用 threadRouter (c.Param("id") = thread_id) - w := s.makeThreadRequest("DELETE", s.threadPath(threadID), nil) - assert.Equal(s.T(), http.StatusNoContent, w.Code) - - // 验证删除后无法获取 - w2 := s.makeThreadRequest("GET", s.threadPath(threadID), nil) - assert.Equal(s.T(), http.StatusNotFound, w2.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestDeleteThread_无效ID返回400() { - // 使用 threadRouter,传入 "invalid" 作为 thread_id - w := s.makeThreadRequest("DELETE", s.threadPath("invalid"), nil) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestDeleteThread_不存在的ID() { - w := s.makeThreadRequest("DELETE", s.threadPath("99999"), nil) - // Delete of non-existent thread: handler returns 500 or 204 depending on service behavior - assert.True(s.T(), w.Code == http.StatusUnprocessableEntity || w.Code == http.StatusNoContent, - "expected 500 or 204 for non-existent thread delete, got %d", w.Code) -} - -// ========== 发送消息测试 ========== - -func (s *CopilotThreadHandlerTestSuite) TestSendMessage_成功发送消息() { - threadID := s.createThreadAndGetID("发送消息测试线程") - - body := map[string]interface{}{ - "content": "你好,这是一条测试消息", - } - - // 使用 messageRouter (c.Param("id") = thread_id) - // 注意: SendMessage handler 同时用 c.Param("id") 获取 threadID 和 accountID, - // 由于只有一个 :id,两者都返回 thread_id,这意味着 accountID 参数是错的。 - // 这是已知的 SendMessage bug(与 CaptainCustomTool 的路由冲突类似)。 - // 测试中 threadID 正确,但 accountID = threadID(而非真实 account_id), - // service 层可能因 accountID 不匹配而返回错误。 - // 如果 service 层不校验 accountID,则消息能成功创建。 - w := s.makeMessageRequest("POST", s.threadPath(threadID)+"/messages", body) - - // 实际行为取决于 service 是否校验 accountID - // 如果 service 不校验 accountID(或 accountID 只用于关联),应返回 201 - // 如果 service 校验 accountID 与 thread 的 account_id 不匹配,应返回 500 - assert.True(s.T(), w.Code == http.StatusCreated || w.Code == http.StatusUnprocessableEntity, - "expected 201 or 500 for SendMessage, got %d", w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestSendMessage_无效threadID返回400() { - // 使用 messageRouter 传入 "invalid" 作为 thread_id - body := map[string]interface{}{ - "content": "测试", - } - w := s.makeMessageRequest("POST", s.threadPath("invalid")+"/messages", body) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestSendMessage_缺少content不会返回400() { - // ShouldBindJSON 不验证 validate 标签,缺少 content 时仍然解析成功 - threadID := s.createThreadAndGetID("发送消息测试线程2") - - body := map[string]interface{}{} - w := s.makeMessageRequest("POST", s.threadPath(threadID)+"/messages", body) - - // ShouldBindJSON 解析成功 → handler 继续执行 - // 缺少 content → 空字符串 → service 创建空内容消息或返回错误 - assert.True(s.T(), w.Code == http.StatusCreated || w.Code == http.StatusUnprocessableEntity, - "expected 201 or 500 for SendMessage without content, got %d", w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestSendMessage_无效JSON返回400() { - threadID := s.createThreadAndGetID("发送消息JSON测试") - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", s.threadPath(threadID)+"/messages", bytes.NewReader([]byte("{invalid}"))) - req.Header.Set("Content-Type", "application/json") - s.messageRouter.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -// ========== 建议回复测试 ========== - -func (s *CopilotThreadHandlerTestSuite) TestGetSuggestedReplies_成功获取建议回复() { - w := s.makeAccountRequest("GET", s.accountPath()+"/suggested_replies?context=客户询问退款政策", nil, map[string]string{}) - - assert.Equal(s.T(), http.StatusOK, w.Code) - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - - data := resp["data"].(map[string]interface{}) - replies := data["replies"].([]interface{}) - assert.GreaterOrEqual(s.T(), len(replies), 1) -} - -func (s *CopilotThreadHandlerTestSuite) TestGetSuggestedReplies_缺少context参数返回400() { - w := s.makeAccountRequest("GET", s.accountPath()+"/suggested_replies", nil, map[string]string{}) - - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestGetSuggestedReplies_无效accountID返回400() { - w := s.makeAccountRequest("GET", "/api/v1/accounts/invalid/suggested_replies?context=test", nil, map[string]string{}) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -// ========== 总结对话测试 ========== - -func (s *CopilotThreadHandlerTestSuite) TestSummarizeConversation_成功总结对话() { - w := s.makeAccountRequest("GET", s.accountPath()+"/summary?context=客户与客服的对话记录", nil, map[string]string{}) - - assert.Equal(s.T(), http.StatusOK, w.Code) - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) -} - -func (s *CopilotThreadHandlerTestSuite) TestSummarizeConversation_缺少context参数返回400() { - w := s.makeAccountRequest("GET", s.accountPath()+"/summary", nil, map[string]string{}) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -// ========== 翻译消息测试 ========== - -func (s *CopilotThreadHandlerTestSuite) TestTranslateMessage_成功翻译消息() { - body := map[string]interface{}{ - "content": "Hello, how are you?", - "target_language": "zh", - } - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot/translate", body, map[string]string{}) - - assert.Equal(s.T(), http.StatusOK, w.Code) - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - - data := resp["data"].(map[string]interface{}) - assert.Equal(s.T(), "zh", data["target_language"]) -} - -func (s *CopilotThreadHandlerTestSuite) TestTranslateMessage_缺少content不会返回400() { - // ShouldBindJSON 不验证 validate 标签 - body := map[string]interface{}{ - "target_language": "zh", - } - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot/translate", body, map[string]string{}) - // ShouldBindJSON 解析成功 → handler 继续执行 - assert.True(s.T(), w.Code == http.StatusOK || w.Code == http.StatusUnprocessableEntity, - "expected 200 or 500 for TranslateMessage without content, got %d", w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestTranslateMessage_缺少target_language不会返回400() { - // ShouldBindJSON 不验证 validate 标签 - body := map[string]interface{}{ - "content": "Hello", - } - w := s.makeAccountRequest("POST", s.accountPath()+"/copilot/translate", body, map[string]string{}) - assert.True(s.T(), w.Code == http.StatusOK || w.Code == http.StatusUnprocessableEntity, - "expected 200 or 500 for TranslateMessage without target_language, got %d", w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestTranslateMessage_无效accountID返回400() { - body := map[string]interface{}{ - "content": "Hello", - "target_language": "zh", - } - w := s.makeAccountRequest("POST", "/api/v1/accounts/invalid/copilot/translate", body, map[string]string{}) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CopilotThreadHandlerTestSuite) TestTranslateMessage_无效JSON返回400() { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", s.accountPath()+"/copilot/translate", bytes.NewReader([]byte("{invalid}"))) - req.Header.Set("Content-Type", "application/json") - s.accountRouter.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func TestCopilotThreadHandlerTestSuite(t *testing.T) { - suite.Run(t, new(CopilotThreadHandlerTestSuite)) -} \ No newline at end of file diff --git a/internal/handler/api/v1/sse_stream_handler.go b/internal/handler/api/v1/sse_stream_handler.go index d8a4fcaf..b9dee952 100644 --- a/internal/handler/api/v1/sse_stream_handler.go +++ b/internal/handler/api/v1/sse_stream_handler.go @@ -74,7 +74,7 @@ func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) { c.Header("X-Accel-Buffering", "no") // disable nginx buffering // Get thread for context - thread, err := h.copilotSvc.GetThread(c.Request.Context(), uint(threadID)) + thread, err := h.copilotSvc.GetThreadByID(c.Request.Context(), uint(threadID)) if err != nil { applogger.L().Errorf("SSE GetThread: %v", err) writeSSEMessage(c, "error", `{"error": "thread not found"}`) diff --git a/internal/model/copilot_models.go b/internal/model/copilot_models.go index 09d0fc76..21e32e05 100644 --- a/internal/model/copilot_models.go +++ b/internal/model/copilot_models.go @@ -20,7 +20,9 @@ type CopilotThread struct { Title string `gorm:"size:255;not null" json:"title"` // Relationships - Messages []CopilotMessage `gorm:"foreignKey:CopilotThreadID" json:"messages,omitempty"` + Messages []CopilotMessage `gorm:"foreignKey:CopilotThreadID" json:"messages,omitempty"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"` } func (CopilotThread) TableName() string { return "copilot_threads" } @@ -51,10 +53,11 @@ func (t *CopilotThread) PreviousHistory(messages []CopilotMessage) []ChatMessage type CopilotMessage struct { Base - AccountID uint `gorm:"index;not null" json:"account_id"` - CopilotThreadID uint `gorm:"index;not null" json:"copilot_thread_id"` - MessageType CopilotMessageType `gorm:"size:50;default:user;not null" json:"message_type"` - Message json.RawMessage `gorm:"type:jsonb;not null" json:"message"` + AccountID uint `gorm:"index;not null" json:"account_id"` + CopilotThreadID uint `gorm:"index;not null" json:"copilot_thread_id"` + MessageType CopilotMessageType `gorm:"size:50;default:user;not null" json:"message_type"` + Message json.RawMessage `gorm:"type:jsonb;not null" json:"message"` + CopilotThread CopilotThread `gorm:"foreignKey:CopilotThreadID" json:"copilot_thread,omitempty"` } func (CopilotMessage) TableName() string { return "copilot_messages" } @@ -80,9 +83,9 @@ func (m *CopilotMessage) GetMessageContent() string { type CopilotSuggestionType string const ( - CopilotSuggestionTypeReply CopilotSuggestionType = "reply" + CopilotSuggestionTypeReply CopilotSuggestionType = "reply" CopilotSuggestionTypeSuggestion CopilotSuggestionType = "suggestion" - CopilotSuggestionTypeSummary CopilotSuggestionType = "summary" + CopilotSuggestionTypeSummary CopilotSuggestionType = "summary" ) type CopilotSuggestionStatus string @@ -95,11 +98,11 @@ const ( type CopilotSuggestionMessage struct { Base - AccountID uint `gorm:"index;not null" json:"account_id"` - ConversationID uint `gorm:"index;not null" json:"conversation_id"` - Content string `gorm:"type:text;not null" json:"content"` - SuggestionType CopilotSuggestionType `gorm:"size:50;not null;default:suggestion" json:"suggestion_type"` - Status CopilotSuggestionStatus `gorm:"size:50;not null;default:pending" json:"status"` + AccountID uint `gorm:"index;not null" json:"account_id"` + ConversationID uint `gorm:"index;not null" json:"conversation_id"` + Content string `gorm:"type:text;not null" json:"content"` + SuggestionType CopilotSuggestionType `gorm:"size:50;not null;default:suggestion" json:"suggestion_type"` + Status CopilotSuggestionStatus `gorm:"size:50;not null;default:pending" json:"status"` } func (CopilotSuggestionMessage) TableName() string { return "copilot_suggestion_messages" } @@ -109,4 +112,4 @@ func (CopilotSuggestionMessage) TableName() string { return "copilot_suggestion_ type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` -} \ No newline at end of file +} diff --git a/internal/repository/copilot_message_repo.go b/internal/repository/copilot_message_repo.go index 35a4dd06..f3e4da94 100644 --- a/internal/repository/copilot_message_repo.go +++ b/internal/repository/copilot_message_repo.go @@ -16,13 +16,17 @@ func NewCopilotMessageRepo(db *gorm.DB) *CopilotMessageRepo { return &CopilotMessageRepo{db: db} } +func (r *CopilotMessageRepo) DB() *gorm.DB { + return r.db +} + func (r *CopilotMessageRepo) Create(ctx context.Context, msg *model.CopilotMessage) error { return r.db.WithContext(ctx).Create(msg).Error } func (r *CopilotMessageRepo) GetByID(ctx context.Context, id uint) (*model.CopilotMessage, error) { var msg model.CopilotMessage - if err := r.db.WithContext(ctx).First(&msg, id).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("CopilotThread.User").Preload("CopilotThread.Assistant").First(&msg, id).Error; err != nil { return nil, err } return &msg, nil @@ -33,7 +37,7 @@ func (r *CopilotMessageRepo) FindByThreadID(ctx context.Context, threadID uint, var count int64 db := r.db.WithContext(ctx).Model(&model.CopilotMessage{}).Where("copilot_thread_id = ?", threadID) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Order("created_at ASC").Find(&msgs).Error; err != nil { + if err := db.Preload("CopilotThread.User").Preload("CopilotThread.Assistant").Offset(offset).Limit(limit).Order("created_at ASC").Find(&msgs).Error; err != nil { return nil, 0, err } return msgs, count, nil @@ -46,4 +50,4 @@ func (r *CopilotMessageRepo) ListByThread(ctx context.Context, threadID uint, of func (r *CopilotMessageRepo) DeleteByThread(ctx context.Context, threadID uint) error { return r.db.WithContext(ctx).Where("copilot_thread_id = ?", threadID).Delete(&model.CopilotMessage{}).Error -} \ No newline at end of file +} diff --git a/internal/repository/copilot_thread_repo.go b/internal/repository/copilot_thread_repo.go index 180f5319..cc68508b 100644 --- a/internal/repository/copilot_thread_repo.go +++ b/internal/repository/copilot_thread_repo.go @@ -16,13 +16,28 @@ func NewCopilotThreadRepo(db *gorm.DB) *CopilotThreadRepo { return &CopilotThreadRepo{db: db} } +func (r *CopilotThreadRepo) DB() *gorm.DB { + return r.db +} + func (r *CopilotThreadRepo) Create(ctx context.Context, thread *model.CopilotThread) error { return r.db.WithContext(ctx).Create(thread).Error } func (r *CopilotThreadRepo) GetByID(ctx context.Context, id uint) (*model.CopilotThread, error) { var thread model.CopilotThread - if err := r.db.WithContext(ctx).Preload("Messages").First(&thread, id).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("Messages").Preload("User").Preload("Assistant").First(&thread, id).Error; err != nil { + return nil, err + } + return &thread, nil +} + +func (r *CopilotThreadRepo) GetByAccountUserAndID(ctx context.Context, accountID, userID, id uint) (*model.CopilotThread, error) { + var thread model.CopilotThread + if err := r.db.WithContext(ctx). + Preload("Messages").Preload("User").Preload("Assistant"). + Where("account_id = ? AND user_id = ? AND id = ?", accountID, userID, id). + First(&thread).Error; err != nil { return nil, err } return &thread, nil @@ -36,12 +51,16 @@ func (r *CopilotThreadRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.CopilotThread{}, id).Error } +func (r *CopilotThreadRepo) DeleteByAccountUser(ctx context.Context, accountID, userID, id uint) error { + return r.db.WithContext(ctx).Where("account_id = ? AND user_id = ? AND id = ?", accountID, userID, id).Delete(&model.CopilotThread{}).Error +} + func (r *CopilotThreadRepo) ListByUser(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.CopilotThread, int64, error) { var threads []model.CopilotThread var count int64 db := r.db.WithContext(ctx).Model(&model.CopilotThread{}).Where("account_id = ? AND user_id = ?", accountID, userID) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Find(&threads).Error; err != nil { + if err := db.Preload("User").Preload("Assistant").Order("created_at DESC").Offset(offset).Limit(limit).Find(&threads).Error; err != nil { return nil, 0, err } return threads, count, nil @@ -69,4 +88,4 @@ func (r *CopilotThreadRepo) FindByAssistantID(ctx context.Context, assistantID u return nil, 0, err } return threads, count, nil -} \ No newline at end of file +} diff --git a/internal/service/copilot_service.go b/internal/service/copilot_service.go index 6a928615..5d2a5d8a 100644 --- a/internal/service/copilot_service.go +++ b/internal/service/copilot_service.go @@ -3,7 +3,9 @@ package service import ( "context" "encoding/json" + "errors" "fmt" + "strings" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" @@ -14,10 +16,11 @@ import ( // CopilotService implements business logic for Copilot (conversation assistance) operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/copilot_threads_controller.rb type CopilotService struct { - threadRepo *repository.CopilotThreadRepo - messageRepo *repository.CopilotMessageRepo - suggestionRepo *repository.CopilotSuggestionRepo - llmProvider llm.Provider + threadRepo *repository.CopilotThreadRepo + messageRepo *repository.CopilotMessageRepo + suggestionRepo *repository.CopilotSuggestionRepo + assistantRepo *repository.CaptainAssistantRepo + llmProvider llm.Provider } // NewCopilotService creates a new CopilotService. @@ -26,41 +29,83 @@ func NewCopilotService( messageRepo *repository.CopilotMessageRepo, suggestionRepo *repository.CopilotSuggestionRepo, llmProvider llm.Provider, + assistantRepo ...*repository.CaptainAssistantRepo, ) *CopilotService { - return &CopilotService{ + s := &CopilotService{ threadRepo: threadRepo, messageRepo: messageRepo, suggestionRepo: suggestionRepo, llmProvider: llmProvider, } + if len(assistantRepo) > 0 { + s.assistantRepo = assistantRepo[0] + } + return s } +const CopilotUnavailableMessage = "You are out of Copilot credits. You can buy more credits from the billing section." + // --- Thread CRUD --- // CreateThreadRequest is the DTO for creating a copilot thread. type CreateThreadRequest struct { - Title string `json:"title" validate:"required"` - AssistantID *uint `json:"assistant_id,omitempty"` + Title string `json:"title"` + Message string `json:"message"` + AssistantID uint `json:"assistant_id"` + ConversationID uint `json:"conversation_id"` } // CreateThread creates a new CopilotThread for a user. func (s *CopilotService) CreateThread(ctx context.Context, accountID, userID uint, req *CreateThreadRequest) (*model.CopilotThread, error) { + message := strings.TrimSpace(req.Message) + if message == "" { + message = strings.TrimSpace(req.Title) + } + if message == "" { + return nil, errors.New("Message is required") + } + if req.AssistantID == 0 { + return nil, errors.New("assistant_id is required") + } + if s.assistantRepo != nil { + if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID); err != nil { + return nil, fmt.Errorf("assistant not found: %w", err) + } + } + assistantID := req.AssistantID thread := &model.CopilotThread{ AccountID: accountID, UserID: userID, - AssistantID: req.AssistantID, - Title: req.Title, + AssistantID: &assistantID, + Title: message, } if err := s.threadRepo.Create(ctx, thread); err != nil { applogger.L().Errorf("Create copilot thread: %v", err) return nil, fmt.Errorf("create thread: %w", err) } + + userMsg, err := s.CreateThreadMessage(ctx, accountID, userID, thread.ID, &SendMessageRequest{Content: message, ConversationID: req.ConversationID}) + if err != nil { + return nil, err + } + if _, err := s.createAssistantReply(ctx, accountID, thread.ID, userMsg, req.ConversationID); err != nil { + return nil, err + } + return s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, thread.ID) +} + +// GetThread retrieves an account/user scoped thread by ID, including its messages. +func (s *CopilotService) GetThread(ctx context.Context, accountID, userID, id uint) (*model.CopilotThread, error) { + thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, id) + if err != nil { + applogger.L().Errorf("Get copilot thread: %v", err) + return nil, fmt.Errorf("get thread: %w", err) + } return thread, nil } -// GetThread retrieves a thread by ID, including its messages. -func (s *CopilotService) GetThread(ctx context.Context, id uint) (*model.CopilotThread, error) { +func (s *CopilotService) GetThreadByID(ctx context.Context, id uint) (*model.CopilotThread, error) { thread, err := s.threadRepo.GetByID(ctx, id) if err != nil { applogger.L().Errorf("Get copilot thread: %v", err) @@ -83,7 +128,9 @@ func (s *CopilotService) ListThreads(ctx context.Context, accountID, userID uint // SendMessageRequest is the DTO for sending a message in a copilot thread. type SendMessageRequest struct { - Content string `json:"content" validate:"required"` + Content string `json:"content"` + Message string `json:"message"` + ConversationID uint `json:"conversation_id"` } // SendMessageResult holds the result of sending a message (includes the assistant reply). @@ -94,23 +141,85 @@ type SendMessageResult struct { // SendMessage sends a user message and generates an assistant reply via LLM. // Reference: Chatwoot CopilotThread#send_message -func (s *CopilotService) SendMessage(ctx context.Context, threadID, accountID uint, req *SendMessageRequest) (*SendMessageResult, error) { - thread, err := s.threadRepo.GetByID(ctx, threadID) +func (s *CopilotService) SendMessage(ctx context.Context, accountID, userID, threadID uint, req *SendMessageRequest) (*SendMessageResult, error) { + thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID) if err != nil { return nil, fmt.Errorf("thread not found: %w", err) } - // Save the user message - userMsg := &model.CopilotMessage{ - AccountID: accountID, - CopilotThreadID: threadID, - MessageType: model.CopilotMessageTypeUser, - Message: json.RawMessage(`{"content": "` + jsonEscape(req.Content) + `"}`), + userMsg, err := s.CreateThreadMessage(ctx, accountID, userID, threadID, req) + if err != nil { + return nil, err } + + assistantMsg, err := s.createAssistantReply(ctx, accountID, threadID, userMsg, req.ConversationID) + if err != nil { + return &SendMessageResult{UserMessage: userMsg}, err + } + + _ = thread + return &SendMessageResult{UserMessage: userMsg, AssistantMessage: assistantMsg}, nil +} + +func (s *CopilotService) CreateThreadMessage(ctx context.Context, accountID, userID, threadID uint, req *SendMessageRequest) (*model.CopilotMessage, error) { + content := strings.TrimSpace(req.Message) + if content == "" { + content = strings.TrimSpace(req.Content) + } + if content == "" { + return nil, errors.New("Message is required") + } + thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID) + if err != nil { + return nil, fmt.Errorf("thread not found: %w", err) + } + message, _ := json.Marshal(map[string]any{"content": content}) + userMsg := &model.CopilotMessage{AccountID: thread.AccountID, CopilotThreadID: thread.ID, MessageType: model.CopilotMessageTypeUser, Message: message} if err := s.messageRepo.Create(ctx, userMsg); err != nil { applogger.L().Errorf("SendMessage create user message: %v", err) return nil, fmt.Errorf("save user message: %w", err) } + return s.messageRepo.GetByID(ctx, userMsg.ID) +} + +func (s *CopilotService) ListThreadMessages(ctx context.Context, accountID, userID, threadID uint, page, perPage int) ([]model.CopilotMessage, int64, error) { + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 1000 + } + thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID) + if err != nil { + return nil, 0, fmt.Errorf("thread not found: %w", err) + } + return s.messageRepo.FindByThreadID(ctx, thread.ID, (page-1)*perPage, perPage) +} + +func (s *CopilotService) createAssistantReply(ctx context.Context, accountID, threadID uint, userMsg *model.CopilotMessage, conversationID uint) (*model.CopilotMessage, error) { + thread, err := s.threadRepo.GetByID(ctx, threadID) + if err != nil { + return nil, fmt.Errorf("thread not found: %w", err) + } + content := CopilotUnavailableMessage + if s.llmProvider != nil { + generated, err := s.generateAssistantContent(ctx, thread, userMsg.GetMessageContent()) + if err == nil && strings.TrimSpace(generated) != "" { + content = generated + } else if err != nil { + applogger.L().Warnf("Copilot LLM unavailable: %v", err) + } + } + message, _ := json.Marshal(map[string]any{"content": content}) + assistantMsg := &model.CopilotMessage{AccountID: accountID, CopilotThreadID: threadID, MessageType: model.CopilotMessageTypeAssistant, Message: message} + if err := s.messageRepo.Create(ctx, assistantMsg); err != nil { + return nil, fmt.Errorf("save assistant message: %w", err) + } + _ = conversationID + return s.messageRepo.GetByID(ctx, assistantMsg.ID) +} + +func (s *CopilotService) generateAssistantContent(ctx context.Context, thread *model.CopilotThread, content string) (string, error) { // Build conversation history for LLM // Convert model.ChatMessage (from PreviousHistory) to llm.ChatMessage @@ -124,7 +233,7 @@ func (s *CopilotService) SendMessage(ctx context.Context, threadID, accountID ui } llmHistory = append(llmHistory, llm.ChatMessage{ Role: "user", - Content: req.Content, + Content: content, }) // Build system prompt @@ -147,7 +256,7 @@ func (s *CopilotService) SendMessage(ctx context.Context, threadID, accountID ui }) if err != nil { applogger.L().Errorf("SendMessage LLM call: %v", err) - return &SendMessageResult{UserMessage: userMsg}, fmt.Errorf("llm generation failed: %w", err) + return "", fmt.Errorf("llm generation failed: %w", err) } assistantContent := "" @@ -155,23 +264,7 @@ func (s *CopilotService) SendMessage(ctx context.Context, threadID, accountID ui assistantContent = llmResp.Choices[0].Message.Content } - // Save the assistant message - assistantMsg := &model.CopilotMessage{ - AccountID: accountID, - CopilotThreadID: threadID, - MessageType: model.CopilotMessageTypeAssistant, - Message: json.RawMessage(`{"content": "` + jsonEscape(assistantContent) + `"}`), - } - if err := s.messageRepo.Create(ctx, assistantMsg); err != nil { - applogger.L().Errorf("SendMessage create assistant message: %v", err) - // Return user message even if assistant message save fails - return &SendMessageResult{UserMessage: userMsg}, fmt.Errorf("save assistant message: %w", err) - } - - return &SendMessageResult{ - UserMessage: userMsg, - AssistantMessage: assistantMsg, - }, nil + return assistantContent, nil } // --- Suggested Replies & Summary --- @@ -305,16 +398,22 @@ func trimReplyPrefix(s string) string { if s[i] == ' ' { continue } -break - } - return s + break + } + return s } // --- Thread deletion & translation --- -// DeleteThread deletes a copilot thread by ID. -func (s *CopilotService) DeleteThread(ctx context.Context, id uint) error { - if err := s.threadRepo.Delete(ctx, id); err != nil { +// DeleteThread deletes an account/user scoped copilot thread by ID. +func (s *CopilotService) DeleteThread(ctx context.Context, accountID, userID, id uint) error { + if _, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, id); err != nil { + return fmt.Errorf("thread not found: %w", err) + } + if err := s.messageRepo.DeleteByThread(ctx, id); err != nil { + return fmt.Errorf("delete thread messages: %w", err) + } + if err := s.threadRepo.DeleteByAccountUser(ctx, accountID, userID, id); err != nil { applogger.L().Errorf("Delete copilot thread: %v", err) return fmt.Errorf("delete thread: %w", err) } @@ -364,8 +463,8 @@ func (s *CopilotService) TranslateMessage(ctx context.Context, accountID uint, r // CreateSuggestionRequest is the DTO for creating a copilot suggestion message. type CreateSuggestionRequest struct { - ConversationID uint `json:"conversation_id" validate:"required"` - Content string `json:"content" validate:"required"` + ConversationID uint `json:"conversation_id" validate:"required"` + Content string `json:"content" validate:"required"` SuggestionType model.CopilotSuggestionType `json:"suggestion_type"` } @@ -395,6 +494,9 @@ func (s *CopilotService) GetCopilotSuggestions(ctx context.Context, accountID, c // CreateCopilotSuggestion creates a new copilot suggestion message. func (s *CopilotService) CreateCopilotSuggestion(ctx context.Context, accountID uint, req *CreateSuggestionRequest) (*model.CopilotSuggestionMessage, error) { + if req.ConversationID == 0 || strings.TrimSpace(req.Content) == "" { + return nil, errors.New("conversation_id and content are required") + } suggestionType := req.SuggestionType if suggestionType == "" { suggestionType = model.CopilotSuggestionTypeSuggestion @@ -427,4 +529,4 @@ func (s *CopilotService) UpdateSuggestionStatus(ctx context.Context, id uint, st } msg.Status = status return msg, nil -} \ No newline at end of file +}