You've already forked agentic-coding-workflow
主流程
This commit is contained in:
@@ -89,10 +89,10 @@ func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.S
|
|||||||
func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
|
func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID) ([]*acp.Session, error) {
|
func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
func (f *fakeAgentKindRepo) ListAllSessions(context.Context) ([]*acp.Session, error) {
|
func (f *fakeAgentKindRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Session, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
|
func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
|
||||||
|
|||||||
@@ -120,10 +120,17 @@ type AgentKindService interface {
|
|||||||
type SessionService interface {
|
type SessionService interface {
|
||||||
Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error)
|
Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error)
|
||||||
Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error)
|
Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error)
|
||||||
List(ctx context.Context, c Caller, all bool) ([]*Session, error)
|
List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error)
|
||||||
Terminate(ctx context.Context, c Caller, id uuid.UUID) error
|
Terminate(ctx context.Context, c Caller, id uuid.UUID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionListFilter 控制 sessions 列表的过滤维度。
|
||||||
|
// All 仅 admin 可用(查看全部用户);RequirementID nil 表示不按需求过滤。
|
||||||
|
type SessionListFilter struct {
|
||||||
|
All bool
|
||||||
|
RequirementID *uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
// CreateSessionInput 是创建 session 的入参。
|
// CreateSessionInput 是创建 session 的入参。
|
||||||
// branch / issue_number / requirement_number 三选一或全空(spec §8.1)。
|
// branch / issue_number / requirement_number 三选一或全空(spec §8.1)。
|
||||||
type CreateSessionInput struct {
|
type CreateSessionInput struct {
|
||||||
|
|||||||
+10
-2
@@ -300,8 +300,16 @@ func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
all := r.URL.Query().Get("all") == "true"
|
f := SessionListFilter{All: r.URL.Query().Get("all") == "true"}
|
||||||
list, err := h.sessSvc.List(r.Context(), c, all)
|
if v := r.URL.Query().Get("requirement_id"); v != "" {
|
||||||
|
id, err := uuid.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement_id 非法"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.RequirementID = &id
|
||||||
|
}
|
||||||
|
list, err := h.sessSvc.List(r.Context(), c, f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id,
|
|||||||
pid, exit_code, last_error, started_at, ended_at
|
pid, exit_code, last_error, started_at, ended_at
|
||||||
FROM acp_sessions
|
FROM acp_sessions
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
|
AND ($2::uuid IS NULL OR requirement_id = $2)
|
||||||
ORDER BY started_at DESC;
|
ORDER BY started_at DESC;
|
||||||
|
|
||||||
-- name: ListAllSessions :many
|
-- name: ListAllSessions :many
|
||||||
@@ -31,6 +32,7 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id,
|
|||||||
branch, cwd_path, is_main_worktree, status,
|
branch, cwd_path, is_main_worktree, status,
|
||||||
pid, exit_code, last_error, started_at, ended_at
|
pid, exit_code, last_error, started_at, ended_at
|
||||||
FROM acp_sessions
|
FROM acp_sessions
|
||||||
|
WHERE ($1::uuid IS NULL OR requirement_id = $1)
|
||||||
ORDER BY started_at DESC;
|
ORDER BY started_at DESC;
|
||||||
|
|
||||||
-- name: UpdateSessionRunning :exec
|
-- name: UpdateSessionRunning :exec
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ func (f *fakeEventRepo) InsertSession(context.Context, *acp.Session) (*acp.Sessi
|
|||||||
func (f *fakeEventRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
|
func (f *fakeEventRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
func (f *fakeEventRepo) ListSessionsByUser(context.Context, uuid.UUID) ([]*acp.Session, error) {
|
func (f *fakeEventRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
func (f *fakeEventRepo) ListAllSessions(context.Context) ([]*acp.Session, error) {
|
func (f *fakeEventRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Session, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
|
func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ type Repository interface {
|
|||||||
// Session
|
// Session
|
||||||
InsertSession(ctx context.Context, s *Session) (*Session, error)
|
InsertSession(ctx context.Context, s *Session) (*Session, error)
|
||||||
GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error)
|
GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error)
|
||||||
ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*Session, error)
|
// requirementID 非 nil 时仅返回关联该需求的 sessions。
|
||||||
ListAllSessions(ctx context.Context) ([]*Session, error)
|
ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error)
|
||||||
|
ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error)
|
||||||
UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSessionID string, pid int32) error
|
UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSessionID string, pid int32) error
|
||||||
UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error
|
UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error
|
||||||
CountActiveSessions(ctx context.Context) (int64, error)
|
CountActiveSessions(ctx context.Context) (int64, error)
|
||||||
@@ -288,8 +289,11 @@ func (r *pgRepo) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, er
|
|||||||
return rowToSession(row), nil
|
return rowToSession(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*Session, error) {
|
func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) {
|
||||||
rows, err := r.q.ListSessionsByUser(ctx, toPgUUID(userID))
|
rows, err := r.q.ListSessionsByUser(ctx, acpsqlc.ListSessionsByUserParams{
|
||||||
|
UserID: toPgUUID(userID),
|
||||||
|
Column2: toPgUUIDPtr(requirementID),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Wrap(err, errs.CodeInternal, "list sessions by user")
|
return nil, errs.Wrap(err, errs.CodeInternal, "list sessions by user")
|
||||||
}
|
}
|
||||||
@@ -300,8 +304,8 @@ func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*S
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepo) ListAllSessions(ctx context.Context) ([]*Session, error) {
|
func (r *pgRepo) ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error) {
|
||||||
rows, err := r.q.ListAllSessions(ctx)
|
rows, err := r.q.ListAllSessions(ctx, toPgUUIDPtr(requirementID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Wrap(err, errs.CodeInternal, "list all sessions")
|
return nil, errs.Wrap(err, errs.CodeInternal, "list all sessions")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,14 +335,14 @@ func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Sess
|
|||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sessionService) List(ctx context.Context, c Caller, all bool) ([]*Session, error) {
|
func (s *sessionService) List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error) {
|
||||||
if all && !c.IsAdmin {
|
if f.All && !c.IsAdmin {
|
||||||
return nil, errs.New(errs.CodeForbidden, "admin only")
|
return nil, errs.New(errs.CodeForbidden, "admin only")
|
||||||
}
|
}
|
||||||
if all {
|
if f.All {
|
||||||
return s.repo.ListAllSessions(ctx)
|
return s.repo.ListAllSessions(ctx, f.RequirementID)
|
||||||
}
|
}
|
||||||
return s.repo.ListSessionsByUser(ctx, c.UserID)
|
return s.repo.ListSessionsByUser(ctx, c.UserID, f.RequirementID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sessionService) Terminate(ctx context.Context, c Caller, id uuid.UUID) error {
|
func (s *sessionService) Terminate(ctx context.Context, c Caller, id uuid.UUID) error {
|
||||||
|
|||||||
@@ -212,10 +212,33 @@ func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { re
|
|||||||
func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
|
func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, _ uuid.UUID) ([]*acp.Session, error) {
|
func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, userID uuid.UUID, reqID *uuid.UUID) ([]*acp.Session, error) {
|
||||||
return nil, nil
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var out []*acp.Session
|
||||||
|
for _, s := range r.sessions {
|
||||||
|
if s.UserID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if reqID != nil && (s.RequirementID == nil || *s.RequirementID != *reqID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) ListAllSessions(_ context.Context, reqID *uuid.UUID) ([]*acp.Session, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var out []*acp.Session
|
||||||
|
for _, s := range r.sessions {
|
||||||
|
if reqID != nil && (s.RequirementID == nil || *s.RequirementID != *reqID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
func (r *fakeAcpRepo) ListAllSessions(_ context.Context) ([]*acp.Session, error) { return nil, nil }
|
|
||||||
func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error {
|
func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -440,3 +463,35 @@ func (f *fakeAcpRepo) ExpirePendingPermissionRequestsBySession(context.Context,
|
|||||||
func (f *fakeAcpRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
|
func (f *fakeAcpRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionService_List_RequirementFilter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
uid := uuid.New()
|
||||||
|
reqID := uuid.New()
|
||||||
|
repo := &fakeAcpRepo{}
|
||||||
|
repo.sessions = []*acp.Session{
|
||||||
|
{ID: uuid.New(), UserID: uid, RequirementID: &reqID},
|
||||||
|
{ID: uuid.New(), UserID: uid},
|
||||||
|
{ID: uuid.New(), UserID: uuid.New(), RequirementID: &reqID},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := acp.NewSessionService(
|
||||||
|
repo, nil, nil, nil, nil, nil, nil,
|
||||||
|
acp.SessionServiceConfig{}, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 普通用户:仅本人 + requirement 过滤
|
||||||
|
list, err := svc.List(context.Background(), acp.Caller{UserID: uid}, acp.SessionListFilter{RequirementID: &reqID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, list, 1)
|
||||||
|
|
||||||
|
// 非 admin 请求 all → 403
|
||||||
|
_, err = svc.List(context.Background(), acp.Caller{UserID: uid}, acp.SessionListFilter{All: true})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// admin all + requirement 过滤:跨用户 2 条
|
||||||
|
list, err = svc.List(context.Background(), acp.Caller{UserID: uid, IsAdmin: true}, acp.SessionListFilter{All: true, RequirementID: &reqID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, list, 2)
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -154,11 +154,12 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id,
|
|||||||
branch, cwd_path, is_main_worktree, status,
|
branch, cwd_path, is_main_worktree, status,
|
||||||
pid, exit_code, last_error, started_at, ended_at
|
pid, exit_code, last_error, started_at, ended_at
|
||||||
FROM acp_sessions
|
FROM acp_sessions
|
||||||
|
WHERE ($1::uuid IS NULL OR requirement_id = $1)
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListAllSessions(ctx context.Context) ([]AcpSession, error) {
|
func (q *Queries) ListAllSessions(ctx context.Context, dollar_1 pgtype.UUID) ([]AcpSession, error) {
|
||||||
rows, err := q.db.Query(ctx, listAllSessions)
|
rows, err := q.db.Query(ctx, listAllSessions, dollar_1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -202,11 +203,17 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id,
|
|||||||
pid, exit_code, last_error, started_at, ended_at
|
pid, exit_code, last_error, started_at, ended_at
|
||||||
FROM acp_sessions
|
FROM acp_sessions
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
|
AND ($2::uuid IS NULL OR requirement_id = $2)
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListSessionsByUser(ctx context.Context, userID pgtype.UUID) ([]AcpSession, error) {
|
type ListSessionsByUserParams struct {
|
||||||
rows, err := q.db.Query(ctx, listSessionsByUser, userID)
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
Column2 pgtype.UUID `json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListSessionsByUser(ctx context.Context, arg ListSessionsByUserParams) ([]AcpSession, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listSessionsByUser, arg.UserID, arg.Column2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -91,7 +91,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
projectSvc := project.NewProjectService(projectRepo, wrappedAudit)
|
projectSvc := project.NewProjectService(projectRepo, wrappedAudit)
|
||||||
requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit)
|
requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit)
|
||||||
issueSvc := project.NewIssueService(projectRepo, wrappedAudit)
|
issueSvc := project.NewIssueService(projectRepo, wrappedAudit)
|
||||||
prototypeSvc := project.NewPrototypeService(projectRepo, wrappedAudit)
|
artifactSvc := project.NewArtifactService(projectRepo, wrappedAudit)
|
||||||
|
|
||||||
notifyRepo := notify.NewPostgresRepository(pool)
|
notifyRepo := notify.NewPostgresRepository(pool)
|
||||||
notifyDispatcher := notify.NewDispatcher(notifyRepo, log)
|
notifyDispatcher := notify.NewDispatcher(notifyRepo, log)
|
||||||
@@ -117,7 +117,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
// userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。
|
// userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。
|
||||||
user.NewHandler(userSvc).Mount(r)
|
user.NewHandler(userSvc).Mount(r)
|
||||||
notify.NewHandler(notifyRepo, userSvc).Mount(r)
|
notify.NewHandler(notifyRepo, userSvc).Mount(r)
|
||||||
project.NewHandler(projectSvc, requirementSvc, issueSvc, prototypeSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
||||||
|
|
||||||
// ===== workspace 模块装配 =====
|
// ===== workspace 模块装配 =====
|
||||||
// 依赖图:
|
// 依赖图:
|
||||||
@@ -263,7 +263,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
Projects: projectSvc,
|
Projects: projectSvc,
|
||||||
Reqs: requirementSvc,
|
Reqs: requirementSvc,
|
||||||
Issues: issueSvc,
|
Issues: issueSvc,
|
||||||
Prototypes: prototypeSvc,
|
Artifacts: artifactSvc,
|
||||||
Workspaces: wsSvc,
|
Workspaces: wsSvc,
|
||||||
Templates: chatTplSvc,
|
Templates: chatTplSvc,
|
||||||
Messages: chatMsgSvc,
|
Messages: chatMsgSvc,
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
|
|||||||
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
||||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
||||||
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
||||||
prototypeSvc := project.NewPrototypeService(projectRepo, auditRec)
|
artifactSvc := project.NewArtifactService(projectRepo, auditRec)
|
||||||
|
|
||||||
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -167,7 +167,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
user.NewHandler(userSvc).Mount(r)
|
user.NewHandler(userSvc).Mount(r)
|
||||||
project.NewHandler(projectSvc, requirementSvc, issueSvc, prototypeSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
@@ -326,7 +326,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
|
|||||||
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
||||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
||||||
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
||||||
prototypeSvc := project.NewPrototypeService(projectRepo, auditRec)
|
artifactSvc := project.NewArtifactService(projectRepo, auditRec)
|
||||||
|
|
||||||
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -368,7 +368,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
user.NewHandler(userSvc).Mount(r)
|
user.NewHandler(userSvc).Mount(r)
|
||||||
project.NewHandler(projectSvc, requirementSvc, issueSvc, prototypeSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
||||||
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ type Conversation struct {
|
|||||||
ProjectID *uuid.UUID
|
ProjectID *uuid.UUID
|
||||||
WorkspaceID *uuid.UUID
|
WorkspaceID *uuid.UUID
|
||||||
IssueID *uuid.UUID
|
IssueID *uuid.UUID
|
||||||
|
RequirementID *uuid.UUID
|
||||||
ModelID uuid.UUID
|
ModelID uuid.UUID
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
Title *string
|
Title *string
|
||||||
@@ -187,6 +188,7 @@ type CreateConversationInput struct {
|
|||||||
ProjectID *uuid.UUID
|
ProjectID *uuid.UUID
|
||||||
WorkspaceID *uuid.UUID
|
WorkspaceID *uuid.UUID
|
||||||
IssueID *uuid.UUID
|
IssueID *uuid.UUID
|
||||||
|
RequirementID *uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConversationFilter 列表过滤参数。
|
// ConversationFilter 列表过滤参数。
|
||||||
@@ -194,6 +196,7 @@ type ConversationFilter struct {
|
|||||||
ProjectID *uuid.UUID
|
ProjectID *uuid.UUID
|
||||||
WorkspaceID *uuid.UUID
|
WorkspaceID *uuid.UUID
|
||||||
IssueID *uuid.UUID
|
IssueID *uuid.UUID
|
||||||
|
RequirementID *uuid.UUID
|
||||||
TitleQuery string
|
TitleQuery string
|
||||||
BeforeID *uuid.UUID // cursor
|
BeforeID *uuid.UUID // cursor
|
||||||
Limit int
|
Limit int
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type CreateConversationReq struct {
|
|||||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||||
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
|
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
|
||||||
IssueID *uuid.UUID `json:"issue_id,omitempty"`
|
IssueID *uuid.UUID `json:"issue_id,omitempty"`
|
||||||
|
RequirementID *uuid.UUID `json:"requirement_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConversationTitleReq is the HTTP request body for PATCH /conversations/{id}.
|
// UpdateConversationTitleReq is the HTTP request body for PATCH /conversations/{id}.
|
||||||
@@ -99,6 +100,7 @@ type ConversationDTO struct {
|
|||||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||||
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
|
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
|
||||||
IssueID *uuid.UUID `json:"issue_id,omitempty"`
|
IssueID *uuid.UUID `json:"issue_id,omitempty"`
|
||||||
|
RequirementID *uuid.UUID `json:"requirement_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -232,6 +234,7 @@ func toConversationDTO(c *Conversation) ConversationDTO {
|
|||||||
ProjectID: c.ProjectID,
|
ProjectID: c.ProjectID,
|
||||||
WorkspaceID: c.WorkspaceID,
|
WorkspaceID: c.WorkspaceID,
|
||||||
IssueID: c.IssueID,
|
IssueID: c.IssueID,
|
||||||
|
RequirementID: c.RequirementID,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
UpdatedAt: c.UpdatedAt,
|
UpdatedAt: c.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ func (h *Handler) listConversations(w http.ResponseWriter, r *http.Request) {
|
|||||||
f.IssueID = &id
|
f.IssueID = &id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v := q.Get("requirement_id"); v != "" {
|
||||||
|
if id, e := parseUUID(v); e == nil {
|
||||||
|
f.RequirementID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
convs, err := h.convSvc.List(r.Context(), uid, f)
|
convs, err := h.convSvc.List(r.Context(), uid, f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
@@ -230,6 +235,7 @@ func (h *Handler) createConversation(w http.ResponseWriter, r *http.Request) {
|
|||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
WorkspaceID: req.WorkspaceID,
|
WorkspaceID: req.WorkspaceID,
|
||||||
IssueID: req.IssueID,
|
IssueID: req.IssueID,
|
||||||
|
RequirementID: req.RequirementID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- name: InsertConversation :one
|
-- name: InsertConversation :one
|
||||||
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title)
|
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, requirement_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetConversation :one
|
-- name: GetConversation :one
|
||||||
@@ -12,9 +12,10 @@ WHERE user_id = $1 AND deleted_at IS NULL
|
|||||||
AND ($2::uuid IS NULL OR project_id = $2)
|
AND ($2::uuid IS NULL OR project_id = $2)
|
||||||
AND ($3::uuid IS NULL OR workspace_id = $3)
|
AND ($3::uuid IS NULL OR workspace_id = $3)
|
||||||
AND ($4::uuid IS NULL OR issue_id = $4)
|
AND ($4::uuid IS NULL OR issue_id = $4)
|
||||||
AND ($5::text = '' OR title ILIKE '%' || $5 || '%')
|
AND ($5::uuid IS NULL OR requirement_id = $5)
|
||||||
|
AND ($6::text = '' OR title ILIKE '%' || $6 || '%')
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT $6;
|
LIMIT $7;
|
||||||
|
|
||||||
-- name: UpdateConversationTitle :exec
|
-- name: UpdateConversationTitle :exec
|
||||||
UPDATE conversations SET title = $2, title_generated_at = NOW(), updated_at = NOW()
|
UPDATE conversations SET title = $2, title_generated_at = NOW(), updated_at = NOW()
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
||||||
chatsqlc "github.com/yan1h/agent-coding-workflow/internal/chat/sqlc"
|
chatsqlc "github.com/yan1h/agent-coding-workflow/internal/chat/sqlc"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Postgres epoch: 2000-01-01 00:00:00 UTC
|
// Postgres epoch: 2000-01-01 00:00:00 UTC
|
||||||
@@ -109,6 +109,7 @@ type InsertConversationParams struct {
|
|||||||
ProjectID *uuid.UUID
|
ProjectID *uuid.UUID
|
||||||
WorkspaceID *uuid.UUID
|
WorkspaceID *uuid.UUID
|
||||||
IssueID *uuid.UUID
|
IssueID *uuid.UUID
|
||||||
|
RequirementID *uuid.UUID
|
||||||
ModelID uuid.UUID
|
ModelID uuid.UUID
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
Title *string
|
Title *string
|
||||||
@@ -313,6 +314,7 @@ func convFromRow(r chatsqlc.Conversation) *Conversation {
|
|||||||
ProjectID: fromPgUUIDPtr(r.ProjectID),
|
ProjectID: fromPgUUIDPtr(r.ProjectID),
|
||||||
WorkspaceID: fromPgUUIDPtr(r.WorkspaceID),
|
WorkspaceID: fromPgUUIDPtr(r.WorkspaceID),
|
||||||
IssueID: fromPgUUIDPtr(r.IssueID),
|
IssueID: fromPgUUIDPtr(r.IssueID),
|
||||||
|
RequirementID: fromPgUUIDPtr(r.RequirementID),
|
||||||
ModelID: fromPgUUID(r.ModelID),
|
ModelID: fromPgUUID(r.ModelID),
|
||||||
SystemPrompt: r.SystemPrompt,
|
SystemPrompt: r.SystemPrompt,
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
@@ -477,6 +479,7 @@ func (r *pgRepo) InsertConversation(ctx context.Context, p InsertConversationPar
|
|||||||
ModelID: toPgUUID(p.ModelID),
|
ModelID: toPgUUID(p.ModelID),
|
||||||
SystemPrompt: p.SystemPrompt,
|
SystemPrompt: p.SystemPrompt,
|
||||||
Title: p.Title,
|
Title: p.Title,
|
||||||
|
RequirementID: toPgUUIDPtr(p.RequirementID),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Wrap(translatePgError(err), errs.CodeInternal, "insert conversation")
|
return nil, errs.Wrap(translatePgError(err), errs.CodeInternal, "insert conversation")
|
||||||
@@ -500,12 +503,15 @@ func (r *pgRepo) ListConversationsByUser(ctx context.Context, userID uuid.UUID,
|
|||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
|
// Column2..Column6 与 SQL 位置参数一一对应:$2=project, $3=workspace,
|
||||||
|
// $4=issue, $5=requirement, $6=title(sqlc 未命名参数按序生成字段)。
|
||||||
rows, err := r.q.ListConversationsByUser(ctx, chatsqlc.ListConversationsByUserParams{
|
rows, err := r.q.ListConversationsByUser(ctx, chatsqlc.ListConversationsByUserParams{
|
||||||
UserID: toPgUUID(userID),
|
UserID: toPgUUID(userID),
|
||||||
Column2: toPgUUIDPtr(f.ProjectID),
|
Column2: toPgUUIDPtr(f.ProjectID),
|
||||||
Column3: toPgUUIDPtr(f.WorkspaceID),
|
Column3: toPgUUIDPtr(f.WorkspaceID),
|
||||||
Column4: toPgUUIDPtr(f.IssueID),
|
Column4: toPgUUIDPtr(f.IssueID),
|
||||||
Column5: f.TitleQuery,
|
Column5: toPgUUIDPtr(f.RequirementID),
|
||||||
|
Column6: f.TitleQuery,
|
||||||
Limit: int32(limit),
|
Limit: int32(limit),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in C
|
|||||||
ProjectID: in.ProjectID,
|
ProjectID: in.ProjectID,
|
||||||
WorkspaceID: in.WorkspaceID,
|
WorkspaceID: in.WorkspaceID,
|
||||||
IssueID: in.IssueID,
|
IssueID: in.IssueID,
|
||||||
|
RequirementID: in.RequirementID,
|
||||||
ModelID: in.ModelID,
|
ModelID: in.ModelID,
|
||||||
SystemPrompt: systemPrompt,
|
SystemPrompt: systemPrompt,
|
||||||
Title: nil,
|
Title: nil,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func (r *convFakeRepo) InsertConversation(_ context.Context, p InsertConversatio
|
|||||||
ProjectID: p.ProjectID,
|
ProjectID: p.ProjectID,
|
||||||
WorkspaceID: p.WorkspaceID,
|
WorkspaceID: p.WorkspaceID,
|
||||||
IssueID: p.IssueID,
|
IssueID: p.IssueID,
|
||||||
|
RequirementID: p.RequirementID,
|
||||||
ModelID: p.ModelID,
|
ModelID: p.ModelID,
|
||||||
SystemPrompt: p.SystemPrompt,
|
SystemPrompt: p.SystemPrompt,
|
||||||
Title: p.Title,
|
Title: p.Title,
|
||||||
@@ -70,9 +71,13 @@ func (r *convFakeRepo) ListConversationsByUser(_ context.Context, userID uuid.UU
|
|||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
var out []Conversation
|
var out []Conversation
|
||||||
for _, c := range r.conversations {
|
for _, c := range r.conversations {
|
||||||
if c.UserID == userID && c.DeletedAt == nil {
|
if c.UserID != userID || c.DeletedAt != nil {
|
||||||
out = append(out, *c)
|
continue
|
||||||
}
|
}
|
||||||
|
if f.RequirementID != nil && (c.RequirementID == nil || *c.RequirementID != *f.RequirementID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, *c)
|
||||||
}
|
}
|
||||||
limit := f.Limit
|
limit := f.Limit
|
||||||
if limit > 0 && len(out) > limit {
|
if limit > 0 && len(out) > limit {
|
||||||
@@ -235,6 +240,33 @@ func TestConversationService_Create_HappyPath(t *testing.T) {
|
|||||||
assert.Equal(t, &userID, entry.UserID)
|
assert.Equal(t, &userID, entry.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConversationService_Create_WithRequirementMount(t *testing.T) {
|
||||||
|
repo := newConvFakeRepo()
|
||||||
|
svc := newConvSvc(repo, noopAuditor{})
|
||||||
|
userID := uuid.Must(uuid.NewV7())
|
||||||
|
m := addModel(repo, true)
|
||||||
|
|
||||||
|
reqID := uuid.Must(uuid.NewV7())
|
||||||
|
projID := uuid.Must(uuid.NewV7())
|
||||||
|
c, err := svc.Create(context.Background(), userID, CreateConversationInput{
|
||||||
|
ModelID: m.ID, ProjectID: &projID, RequirementID: &reqID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, c.RequirementID)
|
||||||
|
assert.Equal(t, reqID, *c.RequirementID)
|
||||||
|
|
||||||
|
// List 按 requirement 过滤:命中 1 条;其他 requirement 不命中。
|
||||||
|
_, err = svc.Create(context.Background(), userID, CreateConversationInput{ModelID: m.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
list, err := svc.List(context.Background(), userID, ConversationFilter{RequirementID: &reqID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, list, 1)
|
||||||
|
other := uuid.Must(uuid.NewV7())
|
||||||
|
list, err = svc.List(context.Background(), userID, ConversationFilter{RequirementID: &other})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, list)
|
||||||
|
}
|
||||||
|
|
||||||
func TestConversationService_Create_WithSystemTemplate(t *testing.T) {
|
func TestConversationService_Create_WithSystemTemplate(t *testing.T) {
|
||||||
repo := newConvFakeRepo()
|
repo := newConvFakeRepo()
|
||||||
svc := newConvSvc(repo, noopAuditor{})
|
svc := newConvSvc(repo, noopAuditor{})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const getConversation = `-- name: GetConversation :one
|
const getConversation = `-- name: GetConversation :one
|
||||||
SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at FROM conversations WHERE id = $1 AND deleted_at IS NULL
|
SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id FROM conversations WHERE id = $1 AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversation, error) {
|
func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversation, error) {
|
||||||
@@ -31,14 +31,15 @@ func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversa
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.DeletedAt,
|
&i.DeletedAt,
|
||||||
|
&i.RequirementID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertConversation = `-- name: InsertConversation :one
|
const insertConversation = `-- name: InsertConversation :one
|
||||||
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title)
|
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, requirement_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at
|
RETURNING id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertConversationParams struct {
|
type InsertConversationParams struct {
|
||||||
@@ -50,6 +51,7 @@ type InsertConversationParams struct {
|
|||||||
ModelID pgtype.UUID `json:"model_id"`
|
ModelID pgtype.UUID `json:"model_id"`
|
||||||
SystemPrompt string `json:"system_prompt"`
|
SystemPrompt string `json:"system_prompt"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversationParams) (Conversation, error) {
|
func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversationParams) (Conversation, error) {
|
||||||
@@ -62,6 +64,7 @@ func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversation
|
|||||||
arg.ModelID,
|
arg.ModelID,
|
||||||
arg.SystemPrompt,
|
arg.SystemPrompt,
|
||||||
arg.Title,
|
arg.Title,
|
||||||
|
arg.RequirementID,
|
||||||
)
|
)
|
||||||
var i Conversation
|
var i Conversation
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -77,19 +80,21 @@ func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversation
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.DeletedAt,
|
&i.DeletedAt,
|
||||||
|
&i.RequirementID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listConversationsByUser = `-- name: ListConversationsByUser :many
|
const listConversationsByUser = `-- name: ListConversationsByUser :many
|
||||||
SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at FROM conversations
|
SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id FROM conversations
|
||||||
WHERE user_id = $1 AND deleted_at IS NULL
|
WHERE user_id = $1 AND deleted_at IS NULL
|
||||||
AND ($2::uuid IS NULL OR project_id = $2)
|
AND ($2::uuid IS NULL OR project_id = $2)
|
||||||
AND ($3::uuid IS NULL OR workspace_id = $3)
|
AND ($3::uuid IS NULL OR workspace_id = $3)
|
||||||
AND ($4::uuid IS NULL OR issue_id = $4)
|
AND ($4::uuid IS NULL OR issue_id = $4)
|
||||||
AND ($5::text = '' OR title ILIKE '%' || $5 || '%')
|
AND ($5::uuid IS NULL OR requirement_id = $5)
|
||||||
|
AND ($6::text = '' OR title ILIKE '%' || $6 || '%')
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT $6
|
LIMIT $7
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListConversationsByUserParams struct {
|
type ListConversationsByUserParams struct {
|
||||||
@@ -97,7 +102,8 @@ type ListConversationsByUserParams struct {
|
|||||||
Column2 pgtype.UUID `json:"column_2"`
|
Column2 pgtype.UUID `json:"column_2"`
|
||||||
Column3 pgtype.UUID `json:"column_3"`
|
Column3 pgtype.UUID `json:"column_3"`
|
||||||
Column4 pgtype.UUID `json:"column_4"`
|
Column4 pgtype.UUID `json:"column_4"`
|
||||||
Column5 string `json:"column_5"`
|
Column5 pgtype.UUID `json:"column_5"`
|
||||||
|
Column6 string `json:"column_6"`
|
||||||
Limit int32 `json:"limit"`
|
Limit int32 `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +114,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat
|
|||||||
arg.Column3,
|
arg.Column3,
|
||||||
arg.Column4,
|
arg.Column4,
|
||||||
arg.Column5,
|
arg.Column5,
|
||||||
|
arg.Column6,
|
||||||
arg.Limit,
|
arg.Limit,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,6 +137,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.DeletedAt,
|
&i.DeletedAt,
|
||||||
|
&i.RequirementID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type ServerDeps struct {
|
|||||||
Projects project.ProjectService
|
Projects project.ProjectService
|
||||||
Reqs project.RequirementService
|
Reqs project.RequirementService
|
||||||
Issues project.IssueService
|
Issues project.IssueService
|
||||||
Prototypes project.PrototypeService
|
Artifacts project.ArtifactService
|
||||||
Workspaces workspace.WorkspaceService
|
Workspaces workspace.WorkspaceService
|
||||||
Templates chat.TemplateService
|
Templates chat.TemplateService
|
||||||
Messages chat.MessageService
|
Messages chat.MessageService
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ var defaultSystemTools = []string{
|
|||||||
"get_issue", "get_requirement",
|
"get_issue", "get_requirement",
|
||||||
"create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue",
|
"create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue",
|
||||||
"create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase",
|
"create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase",
|
||||||
"add_requirement_prototype", "list_requirement_prototypes",
|
"add_requirement_artifact", "list_requirement_artifacts",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。
|
// NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。
|
||||||
|
|||||||
+44
-41
@@ -29,8 +29,8 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
registerCloseRequirement(srv, deps)
|
registerCloseRequirement(srv, deps)
|
||||||
registerReopenRequirement(srv, deps)
|
registerReopenRequirement(srv, deps)
|
||||||
registerSetRequirementPhase(srv, deps)
|
registerSetRequirementPhase(srv, deps)
|
||||||
registerAddRequirementPrototype(srv, deps)
|
registerAddRequirementArtifact(srv, deps)
|
||||||
registerListRequirementPrototypes(srv, deps)
|
registerListRequirementArtifacts(srv, deps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== list_projects =====
|
// ===== list_projects =====
|
||||||
@@ -711,93 +711,96 @@ func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== add_requirement_prototype =====
|
// ===== add_requirement_artifact =====
|
||||||
|
|
||||||
type addRequirementPrototypeIn struct {
|
type addRequirementArtifactIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"requirement number (>=1)"`
|
Number int `json:"number" jsonschema:"requirement number (>=1)"`
|
||||||
Content string `json:"content" jsonschema:"prototype content (non-empty)"`
|
Phase string `json:"phase" jsonschema:"artifact phase: planning|prototyping|auditing"`
|
||||||
|
Content string `json:"content" jsonschema:"artifact content (non-empty)"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
type prototypeDetail struct {
|
type artifactDetail struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
type prototypeCreateOut struct {
|
type artifactCreateOut struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Prototype prototypeDetail `json:"prototype"`
|
Artifact artifactDetail `json:"artifact"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAddRequirementPrototype(srv *mcpsdk.Server, deps ServerDeps) {
|
func registerAddRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
mcpsdk.AddTool(srv,
|
mcpsdk.AddTool(srv,
|
||||||
&mcpsdk.Tool{Name: "add_requirement_prototype", Description: "Add a new prototype version to a requirement."},
|
&mcpsdk.Tool{Name: "add_requirement_artifact", Description: "Add a new phase artifact version (planning/prototyping/auditing) to a requirement."},
|
||||||
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addRequirementPrototypeIn) (*mcpsdk.CallToolResult, prototypeCreateOut, error) {
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addRequirementArtifactIn) (*mcpsdk.CallToolResult, artifactCreateOut, error) {
|
||||||
if err := CheckToolFromCtx(ctx, "add_requirement_prototype"); err != nil {
|
if err := CheckToolFromCtx(ctx, "add_requirement_artifact"); err != nil {
|
||||||
return nil, prototypeCreateOut{}, err
|
return nil, artifactCreateOut{}, err
|
||||||
}
|
}
|
||||||
c, err := deps.Caller.Resolve(ctx)
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, prototypeCreateOut{}, err
|
return nil, artifactCreateOut{}, err
|
||||||
}
|
}
|
||||||
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, prototypeCreateOut{}, err
|
return nil, artifactCreateOut{}, err
|
||||||
}
|
}
|
||||||
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
return nil, prototypeCreateOut{}, err
|
return nil, artifactCreateOut{}, err
|
||||||
}
|
}
|
||||||
proto, err := deps.Prototypes.Create(ctx, c, in.ProjectSlug, in.Number, project.CreatePrototypeInput{
|
art, err := deps.Artifacts.Create(ctx, c, in.ProjectSlug, in.Number, project.CreateArtifactInput{
|
||||||
Content: in.Content, Note: in.Note,
|
Phase: project.Phase(in.Phase), Content: in.Content, Note: in.Note,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, prototypeCreateOut{}, err
|
return nil, artifactCreateOut{}, err
|
||||||
}
|
}
|
||||||
return nil, prototypeCreateOut{OK: true, Prototype: prototypeDetail{
|
return nil, artifactCreateOut{OK: true, Artifact: artifactDetail{
|
||||||
Version: proto.Version, Content: proto.Content, Note: proto.Note,
|
Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note,
|
||||||
CreatedAt: proto.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
}}, nil
|
}}, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== list_requirement_prototypes =====
|
// ===== list_requirement_artifacts =====
|
||||||
|
|
||||||
type listRequirementPrototypesIn struct {
|
type listRequirementArtifactsIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"requirement number (>=1)"`
|
Number int `json:"number" jsonschema:"requirement number (>=1)"`
|
||||||
|
Phase string `json:"phase,omitempty" jsonschema:"filter by phase: planning|prototyping|auditing; empty = all"`
|
||||||
}
|
}
|
||||||
type listRequirementPrototypesOut struct {
|
type listRequirementArtifactsOut struct {
|
||||||
Prototypes []prototypeDetail `json:"prototypes"`
|
Artifacts []artifactDetail `json:"artifacts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerListRequirementPrototypes(srv *mcpsdk.Server, deps ServerDeps) {
|
func registerListRequirementArtifacts(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
mcpsdk.AddTool(srv,
|
mcpsdk.AddTool(srv,
|
||||||
&mcpsdk.Tool{Name: "list_requirement_prototypes", Description: "List prototype versions of a requirement."},
|
&mcpsdk.Tool{Name: "list_requirement_artifacts", Description: "List phase artifact versions of a requirement."},
|
||||||
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementPrototypesIn) (*mcpsdk.CallToolResult, listRequirementPrototypesOut, error) {
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementArtifactsIn) (*mcpsdk.CallToolResult, listRequirementArtifactsOut, error) {
|
||||||
if err := CheckToolFromCtx(ctx, "list_requirement_prototypes"); err != nil {
|
if err := CheckToolFromCtx(ctx, "list_requirement_artifacts"); err != nil {
|
||||||
return nil, listRequirementPrototypesOut{}, err
|
return nil, listRequirementArtifactsOut{}, err
|
||||||
}
|
}
|
||||||
c, err := deps.Caller.Resolve(ctx)
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, listRequirementPrototypesOut{}, err
|
return nil, listRequirementArtifactsOut{}, err
|
||||||
}
|
}
|
||||||
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, listRequirementPrototypesOut{}, err
|
return nil, listRequirementArtifactsOut{}, err
|
||||||
}
|
}
|
||||||
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
return nil, listRequirementPrototypesOut{}, err
|
return nil, listRequirementArtifactsOut{}, err
|
||||||
}
|
}
|
||||||
protos, err := deps.Prototypes.List(ctx, c, in.ProjectSlug, in.Number)
|
arts, err := deps.Artifacts.List(ctx, c, in.ProjectSlug, in.Number, project.Phase(in.Phase))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, listRequirementPrototypesOut{}, err
|
return nil, listRequirementArtifactsOut{}, err
|
||||||
}
|
}
|
||||||
out := listRequirementPrototypesOut{Prototypes: make([]prototypeDetail, 0, len(protos))}
|
out := listRequirementArtifactsOut{Artifacts: make([]artifactDetail, 0, len(arts))}
|
||||||
for _, proto := range protos {
|
for _, art := range arts {
|
||||||
out.Prototypes = append(out.Prototypes, prototypeDetail{
|
out.Artifacts = append(out.Artifacts, artifactDetail{
|
||||||
Version: proto.Version, Content: proto.Content, Note: proto.Note,
|
Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note,
|
||||||
CreatedAt: proto.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return nil, out, nil
|
return nil, out, nil
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Package project 内的 artifact_service.go 实装 ArtifactService。
|
||||||
|
//
|
||||||
|
// 设计取舍:
|
||||||
|
// - Version 在 (Requirement, Phase) 内自增(max+1),由 service 计算后落库;
|
||||||
|
// UNIQUE(requirement_id, phase, version) 兜底竞争,Create 捕获 unique_violation 重试 1 次。
|
||||||
|
// - Phase 仅允许 ArtifactPhases(planning/prototyping/auditing),与 DB CHECK 一致。
|
||||||
|
// - 已 closed requirement:禁止新建产物版本,返回 CodeRequirementClosed。
|
||||||
|
// - 已归档 project:所有写操作经 loadProjectForWrite 拒绝。
|
||||||
|
// - 读操作经 loadProjectForRead 校验可见性。
|
||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type artifactService struct {
|
||||||
|
repo Repository
|
||||||
|
audit audit.Recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArtifactService 构造 ArtifactService。
|
||||||
|
func NewArtifactService(repo Repository, rec audit.Recorder) ArtifactService {
|
||||||
|
return &artifactService{repo: repo, audit: rec}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *artifactService) Create(ctx context.Context, c Caller, slug string, reqNumber int, in CreateArtifactInput) (*Artifact, error) {
|
||||||
|
if !in.Phase.IsArtifactPhase() {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
|
||||||
|
}
|
||||||
|
if in.Content == "" {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "content 必填")
|
||||||
|
}
|
||||||
|
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.Status == StatusClosed {
|
||||||
|
return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止新增产物版本")
|
||||||
|
}
|
||||||
|
out, err := s.createWithRetry(ctx, r.ID, c.UserID, in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, c, "requirement_artifact.create", out.ID.String(), map[string]any{
|
||||||
|
"requirement_id": r.ID.String(), "phase": string(out.Phase), "version": out.Version,
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWithRetry 取 (requirement, phase) 内 max+1 作为新 version;version 撞 UNIQUE 时重试 1 次。
|
||||||
|
func (s *artifactService) createWithRetry(ctx context.Context, reqID, createdBy uuid.UUID, in CreateArtifactInput) (*Artifact, error) {
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
maxVer, err := s.repo.GetMaxArtifactVersion(ctx, reqID, in.Phase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := s.repo.CreateArtifact(ctx, &Artifact{
|
||||||
|
ID: uuid.New(), RequirementID: reqID, Phase: in.Phase,
|
||||||
|
Version: maxVer + 1, Content: in.Content, Note: in.Note,
|
||||||
|
SourceMessageID: in.SourceMessageID,
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if !IsUniqueViolation(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// version 撞了,下一轮重新取 max
|
||||||
|
}
|
||||||
|
return nil, errs.New(errs.CodeInternal, "artifact version 持续冲突")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *artifactService) List(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase) ([]*Artifact, error) {
|
||||||
|
if phase != "" && !phase.IsArtifactPhase() {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
|
||||||
|
}
|
||||||
|
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.repo.ListArtifactsByRequirement(ctx, r.ID, phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *artifactService) GetByVersion(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase, version int) (*Artifact, error) {
|
||||||
|
if !phase.IsArtifactPhase() {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
|
||||||
|
}
|
||||||
|
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.repo.GetArtifactByVersion(ctx, r.ID, phase, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *artifactService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
||||||
|
if s.audit == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := c.UserID
|
||||||
|
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
||||||
|
UserID: &uid, Action: action, TargetType: "requirement_artifact", TargetID: targetID, Metadata: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newArtifactSvc(repo *fakeRepo) (ArtifactService, RequirementService, ProjectService, *spyAudit) {
|
||||||
|
spy := &spyAudit{}
|
||||||
|
return NewArtifactService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_Create_VersionAutoIncrement(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, spy := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
a1, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, a1.Version)
|
||||||
|
|
||||||
|
a2, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v2", Note: "second"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, a2.Version)
|
||||||
|
require.Equal(t, "second", a2.Note)
|
||||||
|
|
||||||
|
require.Contains(t, spy.actions(), "requirement_artifact.create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_Create_VersionsIndependentPerPhase(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// planning 走到 v2,prototyping 的版本序列从 1 重新开始。
|
||||||
|
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p2"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proto, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "x1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, proto.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_Create_RejectsNonArtifactPhase(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, phase := range []Phase{PhaseImplementing, PhaseReviewing, PhaseDone, Phase("bogus"), Phase("")} {
|
||||||
|
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"})
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok, "phase=%s", phase)
|
||||||
|
require.Equal(t, errs.CodeInvalidInput, ae.Code, "phase=%s", phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_Create_RetriesOnVersionConflict(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 首轮 CreateArtifact 撞 UNIQUE,service 应捕获并重试一次后成功。
|
||||||
|
repo.createArtifactFailOnce = true
|
||||||
|
a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, a.Version)
|
||||||
|
require.False(t, repo.createArtifactFailOnce, "失败开关应已被消费")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_Create_RequiresContent(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: ""})
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_Create_BlockedWhenRequirementClosed(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number))
|
||||||
|
|
||||||
|
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_Create_RecordsSourceMessageID(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
srcID := int64(42)
|
||||||
|
a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{
|
||||||
|
Phase: PhaseAuditing, Content: "from chat", SourceMessageID: &srcID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, a.SourceMessageID)
|
||||||
|
require.Equal(t, srcID, *a.SourceMessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_List_FilterByPhaseAndAll(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "x"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "y"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
planning, err := artSvc.List(context.Background(), caller, "demo", r.Number, PhasePlanning)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, planning, 2)
|
||||||
|
require.Equal(t, 1, planning[0].Version)
|
||||||
|
require.Equal(t, 2, planning[1].Version)
|
||||||
|
|
||||||
|
all, err := artSvc.List(context.Background(), caller, "demo", r.Number, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, all, 3)
|
||||||
|
|
||||||
|
_, err = artSvc.List(context.Background(), caller, "demo", r.Number, Phase("bogus"))
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_GetByVersion_NotFound(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePlanning, 99)
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_GetByVersion_HappyPath(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
caller := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "hello", Note: "n"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePrototyping, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "hello", got.Content)
|
||||||
|
require.Equal(t, "n", got.Note)
|
||||||
|
require.Equal(t, owner, got.CreatedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_Create_ForbiddenForOutsider(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
owns := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 私有项目下,非 owner / 非 admin 调用者写入被拒(统一返回 not_found 防探测)。
|
||||||
|
outsider := Caller{UserID: uuid.New()}
|
||||||
|
_, err = artSvc.Create(context.Background(), outsider, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactService_List_ForbiddenForOutsider(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||||
|
owns := Caller{UserID: owner}
|
||||||
|
|
||||||
|
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
outsider := Caller{UserID: uuid.New()}
|
||||||
|
_, err = artSvc.List(context.Background(), outsider, "demo", r.Number, "")
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||||
|
}
|
||||||
+33
-11
@@ -40,6 +40,20 @@ func (p Phase) IsValid() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtifactPhases 是允许产出文档型产物的三个阶段(与 requirement_artifacts.phase
|
||||||
|
// CHECK 约束一致):实施/验收阶段的产出是代码(走 ACP),不在此列。
|
||||||
|
var ArtifactPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing}
|
||||||
|
|
||||||
|
// IsArtifactPhase 报告 p 是否允许创建产物版本。
|
||||||
|
func (p Phase) IsArtifactPhase() bool {
|
||||||
|
for _, x := range ArtifactPhases {
|
||||||
|
if p == x {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Status 是 Requirement / Issue 通用的开/关状态。
|
// Status 是 Requirement / Issue 通用的开/关状态。
|
||||||
type Status string
|
type Status string
|
||||||
|
|
||||||
@@ -115,14 +129,18 @@ type Issue struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prototype 是某条 Requirement 的一个原型设计版本快照。Version 在同一 Requirement
|
// Artifact 是某条 Requirement 在某阶段的一个产物文档版本快照。Version 在
|
||||||
// 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
|
// (RequirementID, Phase) 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
|
||||||
type Prototype struct {
|
// SourceMessageID 非 nil 时指向该产物派生自的 chat assistant 消息(仅追溯用,
|
||||||
|
// schema 层 FK ON DELETE SET NULL,代码层不依赖 chat 模块)。
|
||||||
|
type Artifact struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
RequirementID uuid.UUID
|
RequirementID uuid.UUID
|
||||||
|
Phase Phase
|
||||||
Version int
|
Version int
|
||||||
Content string
|
Content string
|
||||||
Note string
|
Note string
|
||||||
|
SourceMessageID *int64
|
||||||
CreatedBy uuid.UUID
|
CreatedBy uuid.UUID
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -169,11 +187,12 @@ type IssueService interface {
|
|||||||
Reopen(ctx context.Context, c Caller, projectSlug string, number int) error
|
Reopen(ctx context.Context, c Caller, projectSlug string, number int) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrototypeService 暴露 Requirement 原型设计版本的应用服务方法。
|
// ArtifactService 暴露 Requirement 阶段产物版本的应用服务方法。
|
||||||
type PrototypeService interface {
|
type ArtifactService interface {
|
||||||
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error)
|
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreateArtifactInput) (*Artifact, error)
|
||||||
List(ctx context.Context, c Caller, projectSlug string, reqNumber int) ([]*Prototype, error)
|
// List 的 phase 传零值("")表示返回全部阶段产物(done 阶段汇总视图用)。
|
||||||
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, version int) (*Prototype, error)
|
List(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase) ([]*Artifact, error)
|
||||||
|
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase, version int) (*Artifact, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 输入 DTO =====
|
// ===== 输入 DTO =====
|
||||||
@@ -240,11 +259,14 @@ type CreateIssueInput struct {
|
|||||||
WorkspaceID *uuid.UUID
|
WorkspaceID *uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePrototypeInput 创建一个原型设计版本。Version 由 Service 自动计算(max+1),
|
// CreateArtifactInput 创建一个阶段产物版本。Version 由 Service 自动计算
|
||||||
// 不在此处提供。Note 可为空字符串。
|
// ((requirement, phase) 内 max+1),不在此处提供。Phase 必须是 ArtifactPhases
|
||||||
type CreatePrototypeInput struct {
|
// 之一;Note 可为空;SourceMessageID 仅在产物派生自 chat 消息时提供。
|
||||||
|
type CreateArtifactInput struct {
|
||||||
|
Phase Phase
|
||||||
Content string
|
Content string
|
||||||
Note string
|
Note string
|
||||||
|
SourceMessageID *int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
|
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
|
||||||
|
|||||||
+36
-28
@@ -38,14 +38,14 @@ type Handler struct {
|
|||||||
projects ProjectService
|
projects ProjectService
|
||||||
requirements RequirementService
|
requirements RequirementService
|
||||||
issues IssueService
|
issues IssueService
|
||||||
prototypes PrototypeService
|
artifacts ArtifactService
|
||||||
resolver middleware.SessionResolver
|
resolver middleware.SessionResolver
|
||||||
users AdminLookup
|
users AdminLookup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler 构造 Handler。
|
// NewHandler 构造 Handler。
|
||||||
func NewHandler(p ProjectService, r RequirementService, i IssueService, proto PrototypeService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
|
func NewHandler(p ProjectService, r RequirementService, i IssueService, arts ArtifactService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
|
||||||
return &Handler{projects: p, requirements: r, issues: i, prototypes: proto, resolver: resolver, users: users}
|
return &Handler{projects: p, requirements: r, issues: i, artifacts: arts, resolver: resolver, users: users}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。
|
// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。
|
||||||
@@ -67,9 +67,9 @@ func (h *Handler) Mount(r chi.Router) {
|
|||||||
r.Post("/{slug}/requirements/{number}/phase", h.changeRequirementPhase)
|
r.Post("/{slug}/requirements/{number}/phase", h.changeRequirementPhase)
|
||||||
r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
|
r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
|
||||||
r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
|
r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
|
||||||
r.Post("/{slug}/requirements/{number}/prototypes", h.createPrototype)
|
r.Post("/{slug}/requirements/{number}/artifacts", h.createArtifact)
|
||||||
r.Get("/{slug}/requirements/{number}/prototypes", h.listPrototypes)
|
r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts)
|
||||||
r.Get("/{slug}/requirements/{number}/prototypes/{version}", h.getPrototype)
|
r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact)
|
||||||
|
|
||||||
r.Post("/{slug}/issues", h.createIssue)
|
r.Post("/{slug}/issues", h.createIssue)
|
||||||
r.Get("/{slug}/issues", h.listIssues)
|
r.Get("/{slug}/issues", h.listIssues)
|
||||||
@@ -161,12 +161,14 @@ type issueDTO struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type prototypeDTO struct {
|
type artifactDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
RequirementID string `json:"requirement_id"`
|
RequirementID string `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id,omitempty"`
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -231,12 +233,13 @@ func toIssueDTO(i *Issue) issueDTO {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
func toPrototypeDTO(p *Prototype) prototypeDTO {
|
func toArtifactDTO(a *Artifact) artifactDTO {
|
||||||
return prototypeDTO{
|
return artifactDTO{
|
||||||
ID: p.ID.String(), RequirementID: p.RequirementID.String(),
|
ID: a.ID.String(), RequirementID: a.RequirementID.String(),
|
||||||
Version: p.Version, Content: p.Content, Note: p.Note,
|
Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note,
|
||||||
CreatedBy: p.CreatedBy.String(),
|
SourceMessageID: a.SourceMessageID,
|
||||||
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
CreatedBy: a.CreatedBy.String(),
|
||||||
|
CreatedAt: a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,14 +587,16 @@ func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClos
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Prototype endpoints =====
|
// ===== Artifact endpoints =====
|
||||||
|
|
||||||
type createPrototypeReq struct {
|
type createArtifactReq struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) createArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
c, err := h.callerFromCtx(r)
|
c, err := h.callerFromCtx(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
@@ -602,22 +607,23 @@ func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req createPrototypeReq
|
var req createArtifactReq
|
||||||
if err := decodeBody(r, &req); err != nil {
|
if err := decodeBody(r, &req); err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.prototypes.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreatePrototypeInput{
|
out, err := h.artifacts.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreateArtifactInput{
|
||||||
Content: req.Content, Note: req.Note,
|
Phase: Phase(req.Phase), Content: req.Content, Note: req.Note,
|
||||||
|
SourceMessageID: req.SourceMessageID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.WriteJSON(w, http.StatusCreated, toPrototypeDTO(out))
|
httpx.WriteJSON(w, http.StatusCreated, toArtifactDTO(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||||
c, err := h.callerFromCtx(r)
|
c, err := h.callerFromCtx(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
@@ -628,19 +634,20 @@ func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.prototypes.List(r.Context(), c, chi.URLParam(r, "slug"), num)
|
phase := Phase(r.URL.Query().Get("phase"))
|
||||||
|
out, err := h.artifacts.List(r.Context(), c, chi.URLParam(r, "slug"), num, phase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dtos := make([]prototypeDTO, 0, len(out))
|
dtos := make([]artifactDTO, 0, len(out))
|
||||||
for _, x := range out {
|
for _, x := range out {
|
||||||
dtos = append(dtos, toPrototypeDTO(x))
|
dtos = append(dtos, toArtifactDTO(x))
|
||||||
}
|
}
|
||||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototypes": dtos})
|
httpx.WriteJSON(w, http.StatusOK, dtos)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getPrototype(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) getArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
c, err := h.callerFromCtx(r)
|
c, err := h.callerFromCtx(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
@@ -656,12 +663,13 @@ func (h *Handler) getPrototype(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.prototypes.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, ver)
|
phase := Phase(chi.URLParam(r, "phase"))
|
||||||
|
out, err := h.artifacts.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototype": toPrototypeDTO(out)})
|
httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Issue endpoints =====
|
// ===== Issue endpoints =====
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fake
|
|||||||
pSvc := NewProjectService(repo, nil)
|
pSvc := NewProjectService(repo, nil)
|
||||||
rSvc := NewRequirementService(repo, nil)
|
rSvc := NewRequirementService(repo, nil)
|
||||||
iSvc := NewIssueService(repo, nil)
|
iSvc := NewIssueService(repo, nil)
|
||||||
protoSvc := NewPrototypeService(repo, nil)
|
artSvc := NewArtifactService(repo, nil)
|
||||||
resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}}
|
resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}}
|
||||||
h := NewHandler(pSvc, rSvc, iSvc, protoSvc, resolver, fakeAdmin{})
|
h := NewHandler(pSvc, rSvc, iSvc, artSvc, resolver, fakeAdmin{})
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
h.Mount(r)
|
h.Mount(r)
|
||||||
@@ -214,3 +214,54 @@ func TestHandler_Requirement_PATCH_WorkspaceID_TriState(t *testing.T) {
|
|||||||
t.Cleanup(func() { _ = resp5.Body.Close() })
|
t.Cleanup(func() { _ = resp5.Body.Close() })
|
||||||
require.Equal(t, http.StatusBadRequest, resp5.StatusCode)
|
require.Equal(t, http.StatusBadRequest, resp5.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandler_Artifacts_Routes 验证 artifacts 三条路由:创建 201、list 裸数组 +
|
||||||
|
// ?phase= 过滤、{phase}/{version} 获取、非法 phase 400。
|
||||||
|
func TestHandler_Artifacts_Routes(t *testing.T) {
|
||||||
|
owner := uuid.New()
|
||||||
|
tok := "tok"
|
||||||
|
_, h := mountFullHandler(t, tok, owner)
|
||||||
|
checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated)
|
||||||
|
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R"}, http.StatusCreated)
|
||||||
|
|
||||||
|
// 创建 planning v1(带 source_message_id)+ auditing v1
|
||||||
|
resp := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
|
||||||
|
map[string]any{"phase": "planning", "content": "# plan", "note": "n1", "source_message_id": 7})
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
created := decodeBodyMap(t, resp)
|
||||||
|
require.Equal(t, "planning", created["phase"])
|
||||||
|
require.Equal(t, float64(1), created["version"])
|
||||||
|
require.Equal(t, float64(7), created["source_message_id"])
|
||||||
|
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
|
||||||
|
map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated)
|
||||||
|
|
||||||
|
// 非法 phase → 400
|
||||||
|
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
|
||||||
|
map[string]any{"phase": "implementing", "content": "x"}, http.StatusBadRequest)
|
||||||
|
|
||||||
|
// list 裸数组:全量 2 条;?phase=planning 过滤 1 条
|
||||||
|
respList := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts", tok, nil)
|
||||||
|
t.Cleanup(func() { _ = respList.Body.Close() })
|
||||||
|
require.Equal(t, http.StatusOK, respList.StatusCode)
|
||||||
|
var all []map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(respList.Body).Decode(&all))
|
||||||
|
require.Len(t, all, 2)
|
||||||
|
|
||||||
|
respPlanning := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts?phase=planning", tok, nil)
|
||||||
|
t.Cleanup(func() { _ = respPlanning.Body.Close() })
|
||||||
|
require.Equal(t, http.StatusOK, respPlanning.StatusCode)
|
||||||
|
var planning []map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(respPlanning.Body).Decode(&planning))
|
||||||
|
require.Len(t, planning, 1)
|
||||||
|
require.Equal(t, "# plan", planning[0]["content"])
|
||||||
|
|
||||||
|
// get by {phase}/{version}
|
||||||
|
respGet := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/1", tok, nil)
|
||||||
|
got := decodeBodyMap(t, respGet)
|
||||||
|
require.Equal(t, http.StatusOK, respGet.StatusCode)
|
||||||
|
require.Equal(t, "# plan", got["content"])
|
||||||
|
|
||||||
|
// get 非法 phase → 400;不存在版本 → 404
|
||||||
|
checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/bogus/1", tok, nil, http.StatusBadRequest)
|
||||||
|
checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/9", tok, nil, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ type fakeRepo struct {
|
|||||||
projects map[uuid.UUID]*Project
|
projects map[uuid.UUID]*Project
|
||||||
requirements map[uuid.UUID]*Requirement
|
requirements map[uuid.UUID]*Requirement
|
||||||
issues map[uuid.UUID]*Issue
|
issues map[uuid.UUID]*Issue
|
||||||
prototypes map[uuid.UUID]*Prototype
|
artifacts map[uuid.UUID]*Artifact
|
||||||
|
|
||||||
// 置 true 时,下一次 CreatePrototype 返回一次真实的 PG 唯一约束冲突后自动复位,
|
// 置 true 时,下一次 CreateArtifact 返回一次真实的 PG 唯一约束冲突后自动复位,
|
||||||
// 用于覆盖 prototypeService.createWithRetry 的 version 竞争重试分支。
|
// 用于覆盖 artifactService.createWithRetry 的 version 竞争重试分支。
|
||||||
createPrototypeFailOnce bool
|
createArtifactFailOnce bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeRepo() *fakeRepo {
|
func newFakeRepo() *fakeRepo {
|
||||||
@@ -35,14 +35,14 @@ func newFakeRepo() *fakeRepo {
|
|||||||
projects: map[uuid.UUID]*Project{},
|
projects: map[uuid.UUID]*Project{},
|
||||||
requirements: map[uuid.UUID]*Requirement{},
|
requirements: map[uuid.UUID]*Requirement{},
|
||||||
issues: map[uuid.UUID]*Issue{},
|
issues: map[uuid.UUID]*Issue{},
|
||||||
prototypes: map[uuid.UUID]*Prototype{},
|
artifacts: map[uuid.UUID]*Artifact{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v }
|
func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v }
|
||||||
func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v }
|
func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v }
|
||||||
func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v }
|
func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v }
|
||||||
func (r *fakeRepo) clonePrototype(p *Prototype) *Prototype { v := *p; return &v }
|
func (r *fakeRepo) cloneArtifact(a *Artifact) *Artifact { v := *a; return &v }
|
||||||
|
|
||||||
func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) {
|
func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -369,58 +369,63 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepo) GetMaxPrototypeVersion(_ context.Context, requirementID uuid.UUID) (int, error) {
|
func (r *fakeRepo) GetMaxArtifactVersion(_ context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
maxVer := 0
|
maxVer := 0
|
||||||
for _, x := range r.prototypes {
|
for _, x := range r.artifacts {
|
||||||
if x.RequirementID == requirementID && x.Version > maxVer {
|
if x.RequirementID == requirementID && x.Phase == phase && x.Version > maxVer {
|
||||||
maxVer = x.Version
|
maxVer = x.Version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return maxVer, nil
|
return maxVer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepo) CreatePrototype(_ context.Context, in *Prototype) (*Prototype, error) {
|
func (r *fakeRepo) CreateArtifact(_ context.Context, in *Artifact) (*Artifact, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
if r.createPrototypeFailOnce {
|
if r.createArtifactFailOnce {
|
||||||
r.createPrototypeFailOnce = false
|
r.createArtifactFailOnce = false
|
||||||
// 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。
|
// 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。
|
||||||
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
|
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
|
||||||
}
|
}
|
||||||
for _, x := range r.prototypes {
|
for _, x := range r.artifacts {
|
||||||
if x.RequirementID == in.RequirementID && x.Version == in.Version {
|
if x.RequirementID == in.RequirementID && x.Phase == in.Phase && x.Version == in.Version {
|
||||||
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
|
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
in.CreatedAt = time.Now()
|
in.CreatedAt = time.Now()
|
||||||
r.prototypes[in.ID] = r.clonePrototype(in)
|
r.artifacts[in.ID] = r.cloneArtifact(in)
|
||||||
return r.clonePrototype(in), nil
|
return r.cloneArtifact(in), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepo) ListPrototypesByRequirement(_ context.Context, requirementID uuid.UUID) ([]*Prototype, error) {
|
func (r *fakeRepo) ListArtifactsByRequirement(_ context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
out := make([]*Prototype, 0)
|
out := make([]*Artifact, 0)
|
||||||
for _, x := range r.prototypes {
|
for _, x := range r.artifacts {
|
||||||
if x.RequirementID == requirementID {
|
if x.RequirementID == requirementID && (phase == "" || x.Phase == phase) {
|
||||||
out = append(out, r.clonePrototype(x))
|
out = append(out, r.cloneArtifact(x))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Phase != out[j].Phase {
|
||||||
|
return out[i].Phase < out[j].Phase
|
||||||
|
}
|
||||||
|
return out[i].Version < out[j].Version
|
||||||
|
})
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepo) GetPrototypeByVersion(_ context.Context, requirementID uuid.UUID, version int) (*Prototype, error) {
|
func (r *fakeRepo) GetArtifactByVersion(_ context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
for _, x := range r.prototypes {
|
for _, x := range r.artifacts {
|
||||||
if x.RequirementID == requirementID && x.Version == version {
|
if x.RequirementID == requirementID && x.Phase == phase && x.Version == version {
|
||||||
return r.clonePrototype(x), nil
|
return r.cloneArtifact(x), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, errs.New(errs.CodeNotFound, "prototype 不存在")
|
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
// spyAudit 收集所有写入的 audit entry,方便断言。
|
// spyAudit 收集所有写入的 audit entry,方便断言。
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
// Package project 内的 prototype_service.go 实装 PrototypeService。
|
|
||||||
//
|
|
||||||
// 设计取舍:
|
|
||||||
// - Version 在同一 Requirement 内自增(max+1),由 service 计算后落库;
|
|
||||||
// UNIQUE(requirement_id, version) 兜底竞争,Create 捕获 unique_violation 重试 1 次。
|
|
||||||
// - 已 closed requirement:禁止新建原型版本,返回 CodeRequirementClosed。
|
|
||||||
// - 已归档 project:所有写操作经 loadProjectForWrite 拒绝。
|
|
||||||
// - 读操作经 loadProjectForRead 校验可见性。
|
|
||||||
package project
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
||||||
)
|
|
||||||
|
|
||||||
type prototypeService struct {
|
|
||||||
repo Repository
|
|
||||||
audit audit.Recorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPrototypeService 构造 PrototypeService。
|
|
||||||
func NewPrototypeService(repo Repository, rec audit.Recorder) PrototypeService {
|
|
||||||
return &prototypeService{repo: repo, audit: rec}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *prototypeService) Create(ctx context.Context, c Caller, slug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error) {
|
|
||||||
if in.Content == "" {
|
|
||||||
return nil, errs.New(errs.CodeInvalidInput, "content 必填")
|
|
||||||
}
|
|
||||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if r.Status == StatusClosed {
|
|
||||||
return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止新增原型版本")
|
|
||||||
}
|
|
||||||
out, err := s.createWithRetry(ctx, r.ID, c.UserID, in)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.recordAudit(ctx, c, "requirement_prototype.create", out.ID.String(), map[string]any{
|
|
||||||
"requirement_id": r.ID.String(), "version": out.Version,
|
|
||||||
})
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createWithRetry 取 max+1 作为新 version;version 撞 UNIQUE 时重试 1 次。
|
|
||||||
func (s *prototypeService) createWithRetry(ctx context.Context, reqID, createdBy uuid.UUID, in CreatePrototypeInput) (*Prototype, error) {
|
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
|
||||||
maxVer, err := s.repo.GetMaxPrototypeVersion(ctx, reqID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out, err := s.repo.CreatePrototype(ctx, &Prototype{
|
|
||||||
ID: uuid.New(), RequirementID: reqID,
|
|
||||||
Version: maxVer + 1, Content: in.Content, Note: in.Note,
|
|
||||||
CreatedBy: createdBy,
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
if !IsUniqueViolation(err) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// version 撞了,下一轮重新取 max
|
|
||||||
}
|
|
||||||
return nil, errs.New(errs.CodeInternal, "prototype version 持续冲突")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *prototypeService) List(ctx context.Context, c Caller, slug string, reqNumber int) ([]*Prototype, error) {
|
|
||||||
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.repo.ListPrototypesByRequirement(ctx, r.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *prototypeService) GetByVersion(ctx context.Context, c Caller, slug string, reqNumber int, version int) (*Prototype, error) {
|
|
||||||
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.repo.GetPrototypeByVersion(ctx, r.ID, version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *prototypeService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
|
||||||
if s.audit == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uid := c.UserID
|
|
||||||
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
|
||||||
UserID: &uid, Action: action, TargetType: "requirement_prototype", TargetID: targetID, Metadata: meta,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package project
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newProtoSvc(repo *fakeRepo) (PrototypeService, RequirementService, ProjectService, *spyAudit) {
|
|
||||||
spy := &spyAudit{}
|
|
||||||
return NewPrototypeService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_Create_VersionAutoIncrement(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, spy := newProtoSvc(repo)
|
|
||||||
caller := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
p1, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, 1, p1.Version)
|
|
||||||
|
|
||||||
p2, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v2", Note: "second"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, 2, p2.Version)
|
|
||||||
require.Equal(t, "second", p2.Note)
|
|
||||||
|
|
||||||
require.Contains(t, spy.actions(), "requirement_prototype.create")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_Create_RetriesOnVersionConflict(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
|
|
||||||
caller := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// 首轮 CreatePrototype 撞 UNIQUE,service 应捕获并重试一次后成功。
|
|
||||||
repo.createPrototypeFailOnce = true
|
|
||||||
p, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, 1, p.Version)
|
|
||||||
require.False(t, repo.createPrototypeFailOnce, "失败开关应已被消费")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_Create_RequiresContent(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
|
|
||||||
caller := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: ""})
|
|
||||||
ae, ok := errs.As(err)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_Create_BlockedWhenRequirementClosed(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
|
|
||||||
caller := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number))
|
|
||||||
|
|
||||||
_, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"})
|
|
||||||
ae, ok := errs.As(err)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_List_OrderedByVersion(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
|
|
||||||
caller := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
_, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "x"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := protoSvc.List(context.Background(), caller, "demo", r.Number)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, list, 3)
|
|
||||||
require.Equal(t, 1, list[0].Version)
|
|
||||||
require.Equal(t, 2, list[1].Version)
|
|
||||||
require.Equal(t, 3, list[2].Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_GetByVersion_NotFound(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
|
|
||||||
caller := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = protoSvc.GetByVersion(context.Background(), caller, "demo", r.Number, 99)
|
|
||||||
ae, ok := errs.As(err)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_GetByVersion_HappyPath(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
|
|
||||||
caller := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "hello", Note: "n"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
got, err := protoSvc.GetByVersion(context.Background(), caller, "demo", r.Number, 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "hello", got.Content)
|
|
||||||
require.Equal(t, "n", got.Note)
|
|
||||||
require.Equal(t, owner, got.CreatedBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_Create_ForbiddenForOutsider(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
|
|
||||||
owns := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// 私有项目下,非 owner / 非 admin 调用者写入被拒(统一返回 not_found 防探测)。
|
|
||||||
outsider := Caller{UserID: uuid.New()}
|
|
||||||
_, err = protoSvc.Create(context.Background(), outsider, "demo", r.Number, CreatePrototypeInput{Content: "v1"})
|
|
||||||
ae, ok := errs.As(err)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrototypeService_List_ForbiddenForOutsider(t *testing.T) {
|
|
||||||
repo := newFakeRepo()
|
|
||||||
owner := uuid.New()
|
|
||||||
seedProject(t, repo, owner)
|
|
||||||
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
|
|
||||||
owns := Caller{UserID: owner}
|
|
||||||
|
|
||||||
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
outsider := Caller{UserID: uuid.New()}
|
|
||||||
_, err = protoSvc.List(context.Background(), outsider, "demo", r.Number)
|
|
||||||
ae, ok := errs.As(err)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- name: CreateRequirementArtifact :one
|
||||||
|
INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at;
|
||||||
|
|
||||||
|
-- name: ListRequirementArtifactsByRequirement :many
|
||||||
|
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||||
|
FROM requirement_artifacts
|
||||||
|
WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2)
|
||||||
|
ORDER BY phase ASC, version ASC;
|
||||||
|
|
||||||
|
-- name: GetRequirementArtifactByVersion :one
|
||||||
|
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||||
|
FROM requirement_artifacts
|
||||||
|
WHERE requirement_id = $1 AND phase = $2 AND version = $3;
|
||||||
|
|
||||||
|
-- name: GetMaxRequirementArtifactVersion :one
|
||||||
|
SELECT COALESCE(MAX(version), 0)::int FROM requirement_artifacts
|
||||||
|
WHERE requirement_id = $1 AND phase = $2;
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- name: CreateRequirementPrototype :one
|
|
||||||
INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id, requirement_id, version, content, note, created_by, created_at;
|
|
||||||
|
|
||||||
-- name: ListRequirementPrototypesByRequirement :many
|
|
||||||
SELECT id, requirement_id, version, content, note, created_by, created_at
|
|
||||||
FROM requirement_prototypes
|
|
||||||
WHERE requirement_id = $1
|
|
||||||
ORDER BY version ASC;
|
|
||||||
|
|
||||||
-- name: GetRequirementPrototypeByVersion :one
|
|
||||||
SELECT id, requirement_id, version, content, note, created_by, created_at
|
|
||||||
FROM requirement_prototypes
|
|
||||||
WHERE requirement_id = $1 AND version = $2;
|
|
||||||
|
|
||||||
-- name: GetMaxRequirementPrototypeVersion :one
|
|
||||||
SELECT COALESCE(MAX(version), 0)::int FROM requirement_prototypes
|
|
||||||
WHERE requirement_id = $1;
|
|
||||||
@@ -51,11 +51,12 @@ type Repository interface {
|
|||||||
CloseIssue(ctx context.Context, id uuid.UUID) error
|
CloseIssue(ctx context.Context, id uuid.UUID) error
|
||||||
ReopenIssue(ctx context.Context, id uuid.UUID) error
|
ReopenIssue(ctx context.Context, id uuid.UUID) error
|
||||||
|
|
||||||
// Prototype
|
// Artifact
|
||||||
CreatePrototype(ctx context.Context, p *Prototype) (*Prototype, error)
|
CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error)
|
||||||
ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error)
|
// ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。
|
||||||
GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error)
|
ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error)
|
||||||
GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, error)
|
GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error)
|
||||||
|
GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create*
|
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create*
|
||||||
@@ -498,68 +499,79 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Prototype =====
|
// ===== Artifact =====
|
||||||
|
|
||||||
func rowToPrototype(row projectsqlc.RequirementPrototype) *Prototype {
|
func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact {
|
||||||
return &Prototype{
|
return &Artifact{
|
||||||
ID: fromPgUUID(row.ID),
|
ID: fromPgUUID(row.ID),
|
||||||
RequirementID: fromPgUUID(row.RequirementID),
|
RequirementID: fromPgUUID(row.RequirementID),
|
||||||
|
Phase: Phase(row.Phase),
|
||||||
Version: int(row.Version),
|
Version: int(row.Version),
|
||||||
Content: row.Content,
|
Content: row.Content,
|
||||||
Note: row.Note,
|
Note: row.Note,
|
||||||
|
SourceMessageID: row.SourceMessageID,
|
||||||
CreatedBy: fromPgUUID(row.CreatedBy),
|
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||||
CreatedAt: row.CreatedAt.Time,
|
CreatedAt: row.CreatedAt.Time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepo) CreatePrototype(ctx context.Context, in *Prototype) (*Prototype, error) {
|
func (r *pgRepo) CreateArtifact(ctx context.Context, in *Artifact) (*Artifact, error) {
|
||||||
row, err := r.q.CreateRequirementPrototype(ctx, projectsqlc.CreateRequirementPrototypeParams{
|
row, err := r.q.CreateRequirementArtifact(ctx, projectsqlc.CreateRequirementArtifactParams{
|
||||||
ID: toPgUUID(in.ID),
|
ID: toPgUUID(in.ID),
|
||||||
RequirementID: toPgUUID(in.RequirementID),
|
RequirementID: toPgUUID(in.RequirementID),
|
||||||
|
Phase: string(in.Phase),
|
||||||
Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出
|
Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出
|
||||||
Content: in.Content,
|
Content: in.Content,
|
||||||
Note: in.Note,
|
Note: in.Note,
|
||||||
|
SourceMessageID: in.SourceMessageID,
|
||||||
CreatedBy: toPgUUID(in.CreatedBy),
|
CreatedBy: toPgUUID(in.CreatedBy),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if IsUniqueViolation(err) {
|
if IsUniqueViolation(err) {
|
||||||
return nil, err // service 层据此重试一次
|
return nil, err // service 层据此重试一次
|
||||||
}
|
}
|
||||||
return nil, errs.Wrap(err, errs.CodeInternal, "create prototype")
|
return nil, errs.Wrap(err, errs.CodeInternal, "create artifact")
|
||||||
}
|
}
|
||||||
return rowToPrototype(row), nil
|
return rowToArtifact(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepo) ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error) {
|
func (r *pgRepo) ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) {
|
||||||
rows, err := r.q.ListRequirementPrototypesByRequirement(ctx, toPgUUID(requirementID))
|
rows, err := r.q.ListRequirementArtifactsByRequirement(ctx, projectsqlc.ListRequirementArtifactsByRequirementParams{
|
||||||
|
RequirementID: toPgUUID(requirementID),
|
||||||
|
Column2: string(phase),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Wrap(err, errs.CodeInternal, "list prototypes by requirement")
|
return nil, errs.Wrap(err, errs.CodeInternal, "list artifacts by requirement")
|
||||||
}
|
}
|
||||||
out := make([]*Prototype, 0, len(rows))
|
out := make([]*Artifact, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
out = append(out, rowToPrototype(row))
|
out = append(out, rowToArtifact(row))
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepo) GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error) {
|
func (r *pgRepo) GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) {
|
||||||
row, err := r.q.GetRequirementPrototypeByVersion(ctx, projectsqlc.GetRequirementPrototypeByVersionParams{
|
row, err := r.q.GetRequirementArtifactByVersion(ctx, projectsqlc.GetRequirementArtifactByVersionParams{
|
||||||
RequirementID: toPgUUID(requirementID),
|
RequirementID: toPgUUID(requirementID),
|
||||||
|
Phase: string(phase),
|
||||||
Version: int32(version), //nolint:gosec // version 来自 service 层 int,原值即来自 DB int32
|
Version: int32(version), //nolint:gosec // version 来自 service 层 int,原值即来自 DB int32
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errs.New(errs.CodeNotFound, "prototype 不存在")
|
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
|
||||||
}
|
}
|
||||||
return nil, errs.Wrap(err, errs.CodeInternal, "get prototype by version")
|
return nil, errs.Wrap(err, errs.CodeInternal, "get artifact by version")
|
||||||
}
|
}
|
||||||
return rowToPrototype(row), nil
|
return rowToArtifact(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepo) GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, error) {
|
func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
|
||||||
max, err := r.q.GetMaxRequirementPrototypeVersion(ctx, toPgUUID(requirementID))
|
max, err := r.q.GetMaxRequirementArtifactVersion(ctx, projectsqlc.GetMaxRequirementArtifactVersionParams{
|
||||||
|
RequirementID: toPgUUID(requirementID),
|
||||||
|
Phase: string(phase),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, errs.Wrap(err, errs.CodeInternal, "get max prototype version")
|
return 0, errs.Wrap(err, errs.CodeInternal, "get max artifact version")
|
||||||
}
|
}
|
||||||
return int(max), nil
|
return int(max), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: artifacts.sql
|
||||||
|
|
||||||
|
package projectsqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createRequirementArtifact = `-- name: CreateRequirementArtifact :one
|
||||||
|
INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateRequirementArtifactParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateRequirementArtifact(ctx context.Context, arg CreateRequirementArtifactParams) (RequirementArtifact, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createRequirementArtifact,
|
||||||
|
arg.ID,
|
||||||
|
arg.RequirementID,
|
||||||
|
arg.Phase,
|
||||||
|
arg.Version,
|
||||||
|
arg.Content,
|
||||||
|
arg.Note,
|
||||||
|
arg.SourceMessageID,
|
||||||
|
arg.CreatedBy,
|
||||||
|
)
|
||||||
|
var i RequirementArtifact
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.RequirementID,
|
||||||
|
&i.Phase,
|
||||||
|
&i.Version,
|
||||||
|
&i.Content,
|
||||||
|
&i.Note,
|
||||||
|
&i.SourceMessageID,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMaxRequirementArtifactVersion = `-- name: GetMaxRequirementArtifactVersion :one
|
||||||
|
SELECT COALESCE(MAX(version), 0)::int FROM requirement_artifacts
|
||||||
|
WHERE requirement_id = $1 AND phase = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetMaxRequirementArtifactVersionParams struct {
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetMaxRequirementArtifactVersion(ctx context.Context, arg GetMaxRequirementArtifactVersionParams) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getMaxRequirementArtifactVersion, arg.RequirementID, arg.Phase)
|
||||||
|
var column_1 int32
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRequirementArtifactByVersion = `-- name: GetRequirementArtifactByVersion :one
|
||||||
|
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||||
|
FROM requirement_artifacts
|
||||||
|
WHERE requirement_id = $1 AND phase = $2 AND version = $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetRequirementArtifactByVersionParams struct {
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetRequirementArtifactByVersion(ctx context.Context, arg GetRequirementArtifactByVersionParams) (RequirementArtifact, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getRequirementArtifactByVersion, arg.RequirementID, arg.Phase, arg.Version)
|
||||||
|
var i RequirementArtifact
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.RequirementID,
|
||||||
|
&i.Phase,
|
||||||
|
&i.Version,
|
||||||
|
&i.Content,
|
||||||
|
&i.Note,
|
||||||
|
&i.SourceMessageID,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRequirementArtifactsByRequirement = `-- name: ListRequirementArtifactsByRequirement :many
|
||||||
|
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||||
|
FROM requirement_artifacts
|
||||||
|
WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2)
|
||||||
|
ORDER BY phase ASC, version ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListRequirementArtifactsByRequirementParams struct {
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Column2 string `json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListRequirementArtifactsByRequirement(ctx context.Context, arg ListRequirementArtifactsByRequirementParams) ([]RequirementArtifact, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listRequirementArtifactsByRequirement, arg.RequirementID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RequirementArtifact
|
||||||
|
for rows.Next() {
|
||||||
|
var i RequirementArtifact
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.RequirementID,
|
||||||
|
&i.Phase,
|
||||||
|
&i.Version,
|
||||||
|
&i.Content,
|
||||||
|
&i.Note,
|
||||||
|
&i.SourceMessageID,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,12 +277,14 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequirementPrototype struct {
|
type RequirementArtifact struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
Version int32 `json:"version"`
|
Version int32 `json:"version"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.31.1
|
|
||||||
// source: prototypes.sql
|
|
||||||
|
|
||||||
package projectsqlc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
|
||||||
|
|
||||||
const createRequirementPrototype = `-- name: CreateRequirementPrototype :one
|
|
||||||
INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id, requirement_id, version, content, note, created_by, created_at
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateRequirementPrototypeParams struct {
|
|
||||||
ID pgtype.UUID `json:"id"`
|
|
||||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
|
||||||
Version int32 `json:"version"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Note string `json:"note"`
|
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateRequirementPrototype(ctx context.Context, arg CreateRequirementPrototypeParams) (RequirementPrototype, error) {
|
|
||||||
row := q.db.QueryRow(ctx, createRequirementPrototype,
|
|
||||||
arg.ID,
|
|
||||||
arg.RequirementID,
|
|
||||||
arg.Version,
|
|
||||||
arg.Content,
|
|
||||||
arg.Note,
|
|
||||||
arg.CreatedBy,
|
|
||||||
)
|
|
||||||
var i RequirementPrototype
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.RequirementID,
|
|
||||||
&i.Version,
|
|
||||||
&i.Content,
|
|
||||||
&i.Note,
|
|
||||||
&i.CreatedBy,
|
|
||||||
&i.CreatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMaxRequirementPrototypeVersion = `-- name: GetMaxRequirementPrototypeVersion :one
|
|
||||||
SELECT COALESCE(MAX(version), 0)::int FROM requirement_prototypes
|
|
||||||
WHERE requirement_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetMaxRequirementPrototypeVersion(ctx context.Context, requirementID pgtype.UUID) (int32, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getMaxRequirementPrototypeVersion, requirementID)
|
|
||||||
var column_1 int32
|
|
||||||
err := row.Scan(&column_1)
|
|
||||||
return column_1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRequirementPrototypeByVersion = `-- name: GetRequirementPrototypeByVersion :one
|
|
||||||
SELECT id, requirement_id, version, content, note, created_by, created_at
|
|
||||||
FROM requirement_prototypes
|
|
||||||
WHERE requirement_id = $1 AND version = $2
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetRequirementPrototypeByVersionParams struct {
|
|
||||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
|
||||||
Version int32 `json:"version"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetRequirementPrototypeByVersion(ctx context.Context, arg GetRequirementPrototypeByVersionParams) (RequirementPrototype, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getRequirementPrototypeByVersion, arg.RequirementID, arg.Version)
|
|
||||||
var i RequirementPrototype
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.RequirementID,
|
|
||||||
&i.Version,
|
|
||||||
&i.Content,
|
|
||||||
&i.Note,
|
|
||||||
&i.CreatedBy,
|
|
||||||
&i.CreatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const listRequirementPrototypesByRequirement = `-- name: ListRequirementPrototypesByRequirement :many
|
|
||||||
SELECT id, requirement_id, version, content, note, created_by, created_at
|
|
||||||
FROM requirement_prototypes
|
|
||||||
WHERE requirement_id = $1
|
|
||||||
ORDER BY version ASC
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) ListRequirementPrototypesByRequirement(ctx context.Context, requirementID pgtype.UUID) ([]RequirementPrototype, error) {
|
|
||||||
rows, err := q.db.Query(ctx, listRequirementPrototypesByRequirement, requirementID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []RequirementPrototype
|
|
||||||
for rows.Next() {
|
|
||||||
var i RequirementPrototype
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.RequirementID,
|
|
||||||
&i.Version,
|
|
||||||
&i.Content,
|
|
||||||
&i.Note,
|
|
||||||
&i.CreatedBy,
|
|
||||||
&i.CreatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type Conversation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitCredential struct {
|
type GitCredential struct {
|
||||||
@@ -276,6 +277,18 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequirementArtifact struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Version int32 `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
SourceMessageID *int64 `json:"source_message_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
DROP INDEX IF EXISTS conversations_requirement_idx;
|
||||||
|
ALTER TABLE conversations DROP COLUMN IF EXISTS requirement_id;
|
||||||
|
|
||||||
|
-- 还原 0011 的 requirement_prototypes;仅回迁 phase='prototyping' 行,
|
||||||
|
-- planning/auditing 产物在回滚时丢弃(旧 schema 无处安放)。
|
||||||
|
CREATE TABLE requirement_prototypes (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE,
|
||||||
|
version INT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (requirement_id, version)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_requirement_prototypes_req ON requirement_prototypes(requirement_id);
|
||||||
|
INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by, created_at)
|
||||||
|
SELECT id, requirement_id, version, content, note, created_by, created_at
|
||||||
|
FROM requirement_artifacts WHERE phase = 'prototyping';
|
||||||
|
DROP TABLE requirement_artifacts;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 统一三阶段产物表:planning/prototyping/auditing 各自独立版本序列。
|
||||||
|
-- source_message_id 仅作"产物派生自哪条 chat 消息"的追溯,手填产物为 NULL。
|
||||||
|
CREATE TABLE requirement_artifacts (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE,
|
||||||
|
phase TEXT NOT NULL CHECK (phase IN ('planning','prototyping','auditing')),
|
||||||
|
version INT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
source_message_id BIGINT REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (requirement_id, phase, version)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_requirement_artifacts_req_phase ON requirement_artifacts(requirement_id, phase);
|
||||||
|
|
||||||
|
-- 旧 prototype 数据迁移(保留原 id/version/created_at)后下线旧表。
|
||||||
|
INSERT INTO requirement_artifacts
|
||||||
|
(id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at)
|
||||||
|
SELECT id, requirement_id, 'prototyping', version, content, note, NULL, created_by, created_at
|
||||||
|
FROM requirement_prototypes;
|
||||||
|
DROP TABLE requirement_prototypes;
|
||||||
|
|
||||||
|
-- 会话挂载 requirement(新列在表末尾,满足 sqlc 列序约定)。
|
||||||
|
ALTER TABLE conversations ADD COLUMN requirement_id UUID REFERENCES requirements(id);
|
||||||
|
CREATE INDEX conversations_requirement_idx ON conversations(requirement_id) WHERE requirement_id IS NOT NULL;
|
||||||
+7
-2
@@ -127,8 +127,13 @@ export const acpApi = {
|
|||||||
request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }),
|
request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
list: (all?: boolean) =>
|
list: (opts: { all?: boolean; requirement_id?: string } = {}) => {
|
||||||
request<AcpSession[]>('/api/v1/acp/sessions' + (all ? '?all=true' : '')),
|
const q = new URLSearchParams()
|
||||||
|
if (opts.all) q.set('all', 'true')
|
||||||
|
if (opts.requirement_id) q.set('requirement_id', opts.requirement_id)
|
||||||
|
const qs = q.toString()
|
||||||
|
return request<AcpSession[]>('/api/v1/acp/sessions' + (qs ? '?' + qs : ''))
|
||||||
|
},
|
||||||
get: (id: string) =>
|
get: (id: string) =>
|
||||||
request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
|
request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
|
||||||
create: (req: CreateSessionReq) =>
|
create: (req: CreateSessionReq) =>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Conversation {
|
|||||||
project_id?: string | null
|
project_id?: string | null
|
||||||
workspace_id?: string | null
|
workspace_id?: string | null
|
||||||
issue_id?: string | null
|
issue_id?: string | null
|
||||||
|
requirement_id?: string | null
|
||||||
model_id: string
|
model_id: string
|
||||||
system_prompt: string
|
system_prompt: string
|
||||||
title?: string | null
|
title?: string | null
|
||||||
@@ -72,6 +73,7 @@ export interface ListConversationsParams {
|
|||||||
project_id?: string
|
project_id?: string
|
||||||
workspace_id?: string
|
workspace_id?: string
|
||||||
issue_id?: string
|
issue_id?: string
|
||||||
|
requirement_id?: string
|
||||||
q?: string
|
q?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,7 @@ export interface CreateConversationBody {
|
|||||||
project_id?: string
|
project_id?: string
|
||||||
workspace_id?: string
|
workspace_id?: string
|
||||||
issue_id?: string
|
issue_id?: string
|
||||||
|
requirement_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageDailyItem {
|
export interface UsageDailyItem {
|
||||||
|
|||||||
+19
-10
@@ -47,12 +47,17 @@ export interface Issue {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Prototype {
|
// ArtifactPhase 是允许产出文档型产物的三个阶段(与后端 ArtifactPhases 一致)。
|
||||||
|
export type ArtifactPhase = 'planning' | 'prototyping' | 'auditing'
|
||||||
|
|
||||||
|
export interface Artifact {
|
||||||
id: string
|
id: string
|
||||||
requirement_id: string
|
requirement_id: string
|
||||||
|
phase: ArtifactPhase
|
||||||
version: number
|
version: number
|
||||||
content: string
|
content: string
|
||||||
note: string
|
note: string
|
||||||
|
source_message_id?: number | null
|
||||||
created_by: string
|
created_by: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -126,19 +131,23 @@ export const projectsApi = {
|
|||||||
reopenRequirement: (slug: string, number: number) =>
|
reopenRequirement: (slug: string, number: number) =>
|
||||||
request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }),
|
request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }),
|
||||||
|
|
||||||
// ===== Prototype =====
|
// ===== Artifact =====
|
||||||
listPrototypes: (slug: string, number: number) =>
|
listArtifacts: (slug: string, number: number, phase?: ArtifactPhase) =>
|
||||||
request<{ prototypes: Prototype[] }>(
|
request<Artifact[]>(
|
||||||
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`,
|
`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts${phase ? `?phase=${phase}` : ''}`,
|
||||||
),
|
),
|
||||||
createPrototype: (slug: string, number: number, body: { content: string; note?: string }) =>
|
createArtifact: (
|
||||||
request<Prototype>(`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`, {
|
slug: string,
|
||||||
|
number: number,
|
||||||
|
body: { phase: ArtifactPhase; content: string; note?: string; source_message_id?: number },
|
||||||
|
) =>
|
||||||
|
request<Artifact>(`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
getPrototype: (slug: string, number: number, version: number) =>
|
getArtifact: (slug: string, number: number, phase: ArtifactPhase, version: number) =>
|
||||||
request<{ prototype: Prototype }>(
|
request<Artifact>(
|
||||||
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes/${version}`,
|
`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts/${phase}/${version}`,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ===== Issue =====
|
// ===== Issue =====
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
import type { ChatStreamEvent } from './useChatStream'
|
||||||
|
|
||||||
|
const chatApiMock = vi.hoisted(() => ({
|
||||||
|
getConversation: vi.fn(),
|
||||||
|
listMessages: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
cancelMessage: vi.fn(),
|
||||||
|
retryMessage: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/chat', () => ({ chatApi: chatApiMock }))
|
||||||
|
|
||||||
|
// startChatStream 的 mock:记录每次调用,暴露 emit 让测试驱动事件。
|
||||||
|
interface StreamInstance {
|
||||||
|
url: string
|
||||||
|
since: number
|
||||||
|
emit: (ev: ChatStreamEvent) => void
|
||||||
|
stopped: boolean
|
||||||
|
}
|
||||||
|
const streamInstances = vi.hoisted(() => [] as StreamInstance[])
|
||||||
|
|
||||||
|
vi.mock('@/composables/useChatStream', () => ({
|
||||||
|
startChatStream: vi.fn(
|
||||||
|
(
|
||||||
|
url: string,
|
||||||
|
since: number,
|
||||||
|
handlers: { onEvent: (ev: ChatStreamEvent) => void; onError: (e: Error) => void },
|
||||||
|
) => {
|
||||||
|
const inst: StreamInstance = {
|
||||||
|
url,
|
||||||
|
since,
|
||||||
|
emit: (ev) => handlers.onEvent(ev),
|
||||||
|
stopped: false,
|
||||||
|
}
|
||||||
|
streamInstances.push(inst)
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
inst.stopped = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useConversationSession } from './useConversationSession'
|
||||||
|
|
||||||
|
function conv(id: string) {
|
||||||
|
return { id, user_id: 'u1', model_id: 'm1', system_prompt: '', created_at: '', updated_at: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function msg(id: number, status = 'ok', role = 'assistant') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
conversation_id: 'c1',
|
||||||
|
role,
|
||||||
|
content: `msg-${id}`,
|
||||||
|
status,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
streamInstances.length = 0
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useConversationSession', () => {
|
||||||
|
it('openConversation 将 DESC 消息排为 id 升序', async () => {
|
||||||
|
chatApiMock.getConversation.mockResolvedValue({
|
||||||
|
conversation: conv('c1'),
|
||||||
|
messages: [msg(3), msg(2), msg(1)],
|
||||||
|
})
|
||||||
|
const s = useConversationSession()
|
||||||
|
await s.openConversation('c1')
|
||||||
|
expect(s.messages.value.map((m) => m.id)).toEqual([1, 2, 3])
|
||||||
|
expect(s.isStreaming.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('openConversation 发现 pending 消息时续接流', async () => {
|
||||||
|
chatApiMock.getConversation.mockResolvedValue({
|
||||||
|
conversation: conv('c1'),
|
||||||
|
messages: [msg(2, 'pending'), msg(1)],
|
||||||
|
})
|
||||||
|
const s = useConversationSession()
|
||||||
|
await s.openConversation('c1')
|
||||||
|
expect(s.streamingMessageId.value).toBe(2)
|
||||||
|
expect(streamInstances).toHaveLength(1)
|
||||||
|
expect(streamInstances[0].since).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendMessage 追加两条占位消息并累积 text_delta 至 done', async () => {
|
||||||
|
chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] })
|
||||||
|
chatApiMock.sendMessage.mockResolvedValue({
|
||||||
|
user_message_id: 10,
|
||||||
|
assistant_message_id: 11,
|
||||||
|
stream_url: '/stream',
|
||||||
|
})
|
||||||
|
const s = useConversationSession()
|
||||||
|
await s.openConversation('c1')
|
||||||
|
await s.sendMessage('hello')
|
||||||
|
|
||||||
|
expect(s.messages.value.map((m) => [m.id, m.role, m.status])).toEqual([
|
||||||
|
[10, 'user', 'ok'],
|
||||||
|
[11, 'assistant', 'pending'],
|
||||||
|
])
|
||||||
|
expect(s.isStreaming.value).toBe(true)
|
||||||
|
|
||||||
|
const stream = streamInstances[0]
|
||||||
|
stream.emit({ id: 1, event: 'text_delta', data: { text: 'foo' } })
|
||||||
|
stream.emit({ id: 2, event: 'text_delta', data: { text: 'bar' } })
|
||||||
|
stream.emit({ id: 3, event: 'done', data: {} })
|
||||||
|
|
||||||
|
const assistant = s.messages.value[1]
|
||||||
|
expect(assistant.content).toBe('foobar')
|
||||||
|
expect(assistant.status).toBe('ok')
|
||||||
|
expect(s.isStreaming.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error 事件标记消息失败并结束流式', async () => {
|
||||||
|
chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] })
|
||||||
|
chatApiMock.sendMessage.mockResolvedValue({
|
||||||
|
user_message_id: 1,
|
||||||
|
assistant_message_id: 2,
|
||||||
|
stream_url: '/stream',
|
||||||
|
})
|
||||||
|
const s = useConversationSession()
|
||||||
|
await s.openConversation('c1')
|
||||||
|
await s.sendMessage('x')
|
||||||
|
streamInstances[0].emit({ id: 1, event: 'error', data: { message: 'boom' } })
|
||||||
|
expect(s.messages.value[1].status).toBe('error')
|
||||||
|
expect(s.messages.value[1].error_message).toBe('boom')
|
||||||
|
expect(s.isStreaming.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closeCurrent 终止流并清空状态', async () => {
|
||||||
|
chatApiMock.getConversation.mockResolvedValue({
|
||||||
|
conversation: conv('c1'),
|
||||||
|
messages: [msg(2, 'pending')],
|
||||||
|
})
|
||||||
|
const s = useConversationSession()
|
||||||
|
await s.openConversation('c1')
|
||||||
|
expect(streamInstances[0].stopped).toBe(false)
|
||||||
|
s.closeCurrent()
|
||||||
|
expect(streamInstances[0].stopped).toBe(true)
|
||||||
|
expect(s.currentConversation.value).toBeNull()
|
||||||
|
expect(s.messages.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('双实例互不干扰', async () => {
|
||||||
|
chatApiMock.getConversation
|
||||||
|
.mockResolvedValueOnce({ conversation: conv('c1'), messages: [msg(1)] })
|
||||||
|
.mockResolvedValueOnce({ conversation: conv('c2'), messages: [msg(5), msg(4)] })
|
||||||
|
const a = useConversationSession()
|
||||||
|
const b = useConversationSession()
|
||||||
|
await a.openConversation('c1')
|
||||||
|
await b.openConversation('c2')
|
||||||
|
expect(a.messages.value.map((m) => m.id)).toEqual([1])
|
||||||
|
expect(b.messages.value.map((m) => m.id)).toEqual([4, 5])
|
||||||
|
b.closeCurrent()
|
||||||
|
expect(a.currentConversation.value?.id).toBe('c1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
|
||||||
|
import {
|
||||||
|
startChatStream,
|
||||||
|
type ChatStreamEvent,
|
||||||
|
type ChatStreamHandle,
|
||||||
|
} from '@/composables/useChatStream'
|
||||||
|
|
||||||
|
interface UsagePayload {
|
||||||
|
PromptTokens?: number
|
||||||
|
CompletionTokens?: number
|
||||||
|
ThinkingTokens?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextDeltaPayload {
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorPayload {
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaPayload {
|
||||||
|
id?: number
|
||||||
|
role?: string
|
||||||
|
status?: ChatMessage['status']
|
||||||
|
}
|
||||||
|
|
||||||
|
// useConversationSession 承载"单个会话的消息流"全部状态与操作(打开/分页/发送/
|
||||||
|
// 取消/重试/SSE 流式接收)。每次调用返回一个独立实例,互不串扰——全局 /chat 页
|
||||||
|
// 经 chat store 持有一份,需求详情页内嵌面板各自持有一份。
|
||||||
|
// 调用方负责在组件卸载时调用 closeCurrent() 终止流。
|
||||||
|
export function useConversationSession() {
|
||||||
|
const currentConversation = ref<Conversation | null>(null)
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const streamingMessageId = ref<number | null>(null)
|
||||||
|
const streamHandle = ref<ChatStreamHandle | null>(null)
|
||||||
|
|
||||||
|
const isStreaming = computed(() => streamingMessageId.value !== null)
|
||||||
|
|
||||||
|
async function openConversation(id: string) {
|
||||||
|
detachStream()
|
||||||
|
const detail = await chatApi.getConversation(id)
|
||||||
|
currentConversation.value = detail.conversation
|
||||||
|
// 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。
|
||||||
|
messages.value = [...detail.messages].sort((a, b) => a.id - b.id)
|
||||||
|
const pending = detail.messages.find((m) => m.status === 'pending')
|
||||||
|
if (pending) {
|
||||||
|
streamingMessageId.value = pending.id
|
||||||
|
attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlderMessages() {
|
||||||
|
const conv = currentConversation.value
|
||||||
|
if (!conv) return
|
||||||
|
if (messages.value.length === 0) return
|
||||||
|
const beforeId = messages.value.reduce(
|
||||||
|
(min, m) => (m.id < min ? m.id : min),
|
||||||
|
messages.value[0].id,
|
||||||
|
)
|
||||||
|
const older = await chatApi.listMessages(conv.id, { before_id: beforeId })
|
||||||
|
if (older.length === 0) return
|
||||||
|
const existingIds = new Set(messages.value.map((m) => m.id))
|
||||||
|
const fresh = older.filter((m) => !existingIds.has(m.id))
|
||||||
|
if (fresh.length === 0) return
|
||||||
|
// 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。
|
||||||
|
messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(content: string, attachmentIds: string[] = []) {
|
||||||
|
if (!currentConversation.value) return
|
||||||
|
if (streamingMessageId.value !== null) return
|
||||||
|
const conv = currentConversation.value
|
||||||
|
const resp = await chatApi.sendMessage(conv.id, {
|
||||||
|
content,
|
||||||
|
attachment_ids: attachmentIds,
|
||||||
|
})
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
messages.value.push({
|
||||||
|
id: resp.user_message_id,
|
||||||
|
conversation_id: conv.id,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
status: 'ok',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
messages.value.push({
|
||||||
|
id: resp.assistant_message_id,
|
||||||
|
conversation_id: conv.id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
streamingMessageId.value = resp.assistant_message_id
|
||||||
|
attachStream(resp.stream_url, resp.user_message_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelMessage() {
|
||||||
|
const id = streamingMessageId.value
|
||||||
|
if (id == null) return
|
||||||
|
await chatApi.cancelMessage(id)
|
||||||
|
detachStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryMessage(messageId: number) {
|
||||||
|
if (!currentConversation.value) return
|
||||||
|
const resp = await chatApi.retryMessage(messageId)
|
||||||
|
const idx = messages.value.findIndex((m) => m.id === messageId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
const old = messages.value[idx]
|
||||||
|
messages.value[idx] = {
|
||||||
|
...old,
|
||||||
|
id: resp.assistant_message_id,
|
||||||
|
content: '',
|
||||||
|
thinking: null,
|
||||||
|
status: 'pending',
|
||||||
|
error_message: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamingMessageId.value = resp.assistant_message_id
|
||||||
|
attachStream(resp.stream_url, resp.assistant_message_id - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCurrent() {
|
||||||
|
detachStream()
|
||||||
|
currentConversation.value = null
|
||||||
|
messages.value = []
|
||||||
|
streamingMessageId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachStream(streamUrl: string, sinceMessageID: number) {
|
||||||
|
detachStream()
|
||||||
|
streamHandle.value = startChatStream(streamUrl, sinceMessageID, {
|
||||||
|
onEvent: (ev) => applyStreamEvent(ev),
|
||||||
|
onClose: () => {
|
||||||
|
streamHandle.value = null
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('chat stream error', err)
|
||||||
|
const id = streamingMessageId.value
|
||||||
|
if (id != null) {
|
||||||
|
const msg = messages.value.find((m) => m.id === id)
|
||||||
|
if (msg && msg.status === 'pending') {
|
||||||
|
msg.status = 'error'
|
||||||
|
msg.error_message = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamingMessageId.value = null
|
||||||
|
streamHandle.value = null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachStream() {
|
||||||
|
streamHandle.value?.stop()
|
||||||
|
streamHandle.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStreamEvent(ev: ChatStreamEvent) {
|
||||||
|
const targetId = streamingMessageId.value
|
||||||
|
if (targetId == null) return
|
||||||
|
const msg = messages.value.find((m) => m.id === targetId)
|
||||||
|
if (!msg) return
|
||||||
|
switch (ev.event) {
|
||||||
|
case 'message_meta': {
|
||||||
|
const d = ev.data as MetaPayload
|
||||||
|
if (d.status) msg.status = d.status
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'text_delta': {
|
||||||
|
const d = ev.data as TextDeltaPayload
|
||||||
|
if (d.text) msg.content += d.text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'thinking_delta': {
|
||||||
|
const d = ev.data as TextDeltaPayload
|
||||||
|
if (d.text) msg.thinking = (msg.thinking ?? '') + d.text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'usage': {
|
||||||
|
const u = ev.data as UsagePayload
|
||||||
|
msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens
|
||||||
|
msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens
|
||||||
|
msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'done':
|
||||||
|
// 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。
|
||||||
|
// tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。
|
||||||
|
msg.status = 'ok'
|
||||||
|
streamingMessageId.value = null
|
||||||
|
break
|
||||||
|
case 'error': {
|
||||||
|
const d = ev.data as ErrorPayload
|
||||||
|
msg.status = 'error'
|
||||||
|
msg.error_message = d.message ?? d.code ?? 'stream error'
|
||||||
|
streamingMessageId.value = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentConversation,
|
||||||
|
messages,
|
||||||
|
streamingMessageId,
|
||||||
|
isStreaming,
|
||||||
|
openConversation,
|
||||||
|
loadOlderMessages,
|
||||||
|
sendMessage,
|
||||||
|
cancelMessage,
|
||||||
|
retryMessage,
|
||||||
|
closeCurrent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationSession = ReturnType<typeof useConversationSession>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
import type { Artifact } from '@/api/projects'
|
||||||
|
import {
|
||||||
|
buildRequirementSystemPrompt,
|
||||||
|
PHASE_ROLE_PROMPTS,
|
||||||
|
PREV_ARTIFACT_PHASE,
|
||||||
|
} from './requirementPhasePrompts'
|
||||||
|
|
||||||
|
const req = { number: 7, title: '导出报表', description: '支持导出 Excel' }
|
||||||
|
|
||||||
|
function artifact(content: string): Artifact {
|
||||||
|
return {
|
||||||
|
id: 'a1',
|
||||||
|
requirement_id: 'r1',
|
||||||
|
phase: 'planning',
|
||||||
|
version: 2,
|
||||||
|
content,
|
||||||
|
note: '',
|
||||||
|
created_by: 'u1',
|
||||||
|
created_at: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildRequirementSystemPrompt', () => {
|
||||||
|
it('planning:角色模板 + 需求信息,无上一阶段产物', () => {
|
||||||
|
const out = buildRequirementSystemPrompt('planning', req)
|
||||||
|
expect(out).toContain(PHASE_ROLE_PROMPTS.planning)
|
||||||
|
expect(out).toContain('需求 #7:导出报表')
|
||||||
|
expect(out).toContain('支持导出 Excel')
|
||||||
|
expect(out).not.toContain('上一阶段')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prototyping:注入上一阶段(planning)产物', () => {
|
||||||
|
const out = buildRequirementSystemPrompt('prototyping', req, artifact('# 规划文档内容'))
|
||||||
|
expect(out).toContain(PHASE_ROLE_PROMPTS.prototyping)
|
||||||
|
expect(out).toContain('上一阶段(planning)产物 v2')
|
||||||
|
expect(out).toContain('# 规划文档内容')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('描述为空时不渲染需求描述段', () => {
|
||||||
|
const out = buildRequirementSystemPrompt('planning', { ...req, description: '' })
|
||||||
|
expect(out).not.toContain('需求描述')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('超长产物内容截断并标注', () => {
|
||||||
|
const long = 'x'.repeat(10000)
|
||||||
|
const out = buildRequirementSystemPrompt('auditing', req, artifact(long))
|
||||||
|
expect(out).toContain('已截断')
|
||||||
|
expect(out.length).toBeLessThan(10000 + PHASE_ROLE_PROMPTS.auditing.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PREV_ARTIFACT_PHASE 链:planning←无、prototyping←planning、auditing←prototyping', () => {
|
||||||
|
expect(PREV_ARTIFACT_PHASE.planning).toBeNull()
|
||||||
|
expect(PREV_ARTIFACT_PHASE.prototyping).toBe('planning')
|
||||||
|
expect(PREV_ARTIFACT_PHASE.auditing).toBe('prototyping')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
|
||||||
|
|
||||||
|
// 上一阶段产物注入 system prompt 的最大字符数,超出截断并标注。
|
||||||
|
const MAX_PREV_ARTIFACT_CHARS = 8000
|
||||||
|
|
||||||
|
// 三个 AI 对话阶段的角色模板。创建会话时与需求上下文拼接为 system prompt,
|
||||||
|
// 组装结果在创建表单中可见可改(前端组装方案,零后端耦合)。
|
||||||
|
export const PHASE_ROLE_PROMPTS: Record<ArtifactPhase, string> = {
|
||||||
|
planning: `你是一名资深产品规划助手。你的任务是与用户深入讨论需求,澄清目标、范围、用户场景与约束,最终形成一份结构化的规划文档(Markdown)。
|
||||||
|
讨论时主动提问补全缺失信息;输出规划文档时包含:背景与目标、范围(含不做什么)、用户场景、功能拆解、风险与依赖、验收标准。`,
|
||||||
|
prototyping: `你是一名资深原型设计助手。基于已确认的规划,与用户讨论并产出原型设计文档(Markdown)。
|
||||||
|
内容应包含:页面/界面结构、交互流程、状态与边界情况、数据展示与输入约束;可用文字线框、列表与表格描述布局。`,
|
||||||
|
auditing: `你是一名严格的方案评审助手。基于规划与原型,对方案进行评审并产出评审报告(Markdown)。
|
||||||
|
评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上一阶段产物来源:prototyping ← planning、auditing ← prototyping、planning 无。
|
||||||
|
export const PREV_ARTIFACT_PHASE: Record<ArtifactPhase, ArtifactPhase | null> = {
|
||||||
|
planning: null,
|
||||||
|
prototyping: 'planning',
|
||||||
|
auditing: 'prototyping',
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequirementSystemPrompt 组装阶段会话的 system prompt:
|
||||||
|
// 角色模板 + 需求标题/描述 + 上一阶段产物最新版(如有,超长截断)。
|
||||||
|
export function buildRequirementSystemPrompt(
|
||||||
|
phase: ArtifactPhase,
|
||||||
|
requirement: Pick<Requirement, 'number' | 'title' | 'description'>,
|
||||||
|
prevArtifact?: Artifact | null,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [PHASE_ROLE_PROMPTS[phase]]
|
||||||
|
parts.push(`## 当前需求\n需求 #${requirement.number}:${requirement.title}`)
|
||||||
|
if (requirement.description) {
|
||||||
|
parts.push(`### 需求描述\n${requirement.description}`)
|
||||||
|
}
|
||||||
|
if (prevArtifact) {
|
||||||
|
let content = prevArtifact.content
|
||||||
|
if (content.length > MAX_PREV_ARTIFACT_CHARS) {
|
||||||
|
content = content.slice(0, MAX_PREV_ARTIFACT_CHARS) + '\n\n……(内容过长,已截断)'
|
||||||
|
}
|
||||||
|
parts.push(`## 上一阶段(${prevArtifact.phase})产物 v${prevArtifact.version}\n${content}`)
|
||||||
|
}
|
||||||
|
return parts.join('\n\n')
|
||||||
|
}
|
||||||
@@ -60,11 +60,11 @@ export const useAcpStore = defineStore('acp', () => {
|
|||||||
return acpApi.agentKinds.get(id)
|
return acpApi.agentKinds.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listSessions(all = false) {
|
async function listSessions(all = false, requirementId?: string) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
sessions.value = await acpApi.sessions.list(all)
|
sessions.value = await acpApi.sessions.list({ all, requirement_id: requirementId })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'failed'
|
error.value = e instanceof Error ? e.message : 'failed'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+19
-217
@@ -1,239 +1,41 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import { chatApi, type Conversation, type ListConversationsParams } from '@/api/chat'
|
||||||
chatApi,
|
import { useConversationSession } from '@/composables/useConversationSession'
|
||||||
type ChatMessage,
|
|
||||||
type Conversation,
|
|
||||||
type ListConversationsParams,
|
|
||||||
} from '@/api/chat'
|
|
||||||
import {
|
|
||||||
startChatStream,
|
|
||||||
type ChatStreamEvent,
|
|
||||||
type ChatStreamHandle,
|
|
||||||
} from '@/composables/useChatStream'
|
|
||||||
|
|
||||||
interface UsagePayload {
|
|
||||||
PromptTokens?: number
|
|
||||||
CompletionTokens?: number
|
|
||||||
ThinkingTokens?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextDeltaPayload {
|
|
||||||
text?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorPayload {
|
|
||||||
code?: string
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetaPayload {
|
|
||||||
id?: number
|
|
||||||
role?: string
|
|
||||||
status?: ChatMessage['status']
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// chat store = 全局 /chat 页那一份会话实例 + 会话列表。
|
||||||
|
// "单会话消息流"逻辑在 useConversationSession 中(可多实例,详情页内嵌面板自建),
|
||||||
|
// 这里持有一份并原样转发,保持对外 API 不变(ChatDetailView 零改动)。
|
||||||
export const useChatStore = defineStore('chat', () => {
|
export const useChatStore = defineStore('chat', () => {
|
||||||
const conversations = ref<Conversation[]>([])
|
const conversations = ref<Conversation[]>([])
|
||||||
const currentConversation = ref<Conversation | null>(null)
|
const session = useConversationSession()
|
||||||
const messages = ref<ChatMessage[]>([])
|
|
||||||
const streamingMessageId = ref<number | null>(null)
|
|
||||||
const streamHandle = ref<ChatStreamHandle | null>(null)
|
|
||||||
|
|
||||||
const isStreaming = computed(() => streamingMessageId.value !== null)
|
|
||||||
|
|
||||||
async function loadConversations(filter: ListConversationsParams = {}) {
|
async function loadConversations(filter: ListConversationsParams = {}) {
|
||||||
conversations.value = await chatApi.listConversations(filter)
|
conversations.value = await chatApi.listConversations(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openConversation(id: string) {
|
|
||||||
detachStream()
|
|
||||||
const detail = await chatApi.getConversation(id)
|
|
||||||
currentConversation.value = detail.conversation
|
|
||||||
// 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。
|
|
||||||
messages.value = [...detail.messages].sort((a, b) => a.id - b.id)
|
|
||||||
const pending = detail.messages.find((m) => m.status === 'pending')
|
|
||||||
if (pending) {
|
|
||||||
streamingMessageId.value = pending.id
|
|
||||||
attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOlderMessages() {
|
|
||||||
const conv = currentConversation.value
|
|
||||||
if (!conv) return
|
|
||||||
if (messages.value.length === 0) return
|
|
||||||
const beforeId = messages.value.reduce(
|
|
||||||
(min, m) => (m.id < min ? m.id : min),
|
|
||||||
messages.value[0].id,
|
|
||||||
)
|
|
||||||
const older = await chatApi.listMessages(conv.id, { before_id: beforeId })
|
|
||||||
if (older.length === 0) return
|
|
||||||
const existingIds = new Set(messages.value.map((m) => m.id))
|
|
||||||
const fresh = older.filter((m) => !existingIds.has(m.id))
|
|
||||||
if (fresh.length === 0) return
|
|
||||||
// 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。
|
|
||||||
messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage(content: string, attachmentIds: string[] = []) {
|
|
||||||
if (!currentConversation.value) return
|
|
||||||
if (streamingMessageId.value !== null) return
|
|
||||||
const conv = currentConversation.value
|
|
||||||
const resp = await chatApi.sendMessage(conv.id, {
|
|
||||||
content,
|
|
||||||
attachment_ids: attachmentIds,
|
|
||||||
})
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
messages.value.push({
|
|
||||||
id: resp.user_message_id,
|
|
||||||
conversation_id: conv.id,
|
|
||||||
role: 'user',
|
|
||||||
content,
|
|
||||||
status: 'ok',
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
messages.value.push({
|
|
||||||
id: resp.assistant_message_id,
|
|
||||||
conversation_id: conv.id,
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
status: 'pending',
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
streamingMessageId.value = resp.assistant_message_id
|
|
||||||
attachStream(resp.stream_url, resp.user_message_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelMessage() {
|
|
||||||
const id = streamingMessageId.value
|
|
||||||
if (id == null) return
|
|
||||||
await chatApi.cancelMessage(id)
|
|
||||||
detachStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retryMessage(messageId: number) {
|
|
||||||
if (!currentConversation.value) return
|
|
||||||
const resp = await chatApi.retryMessage(messageId)
|
|
||||||
const idx = messages.value.findIndex((m) => m.id === messageId)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const old = messages.value[idx]
|
|
||||||
messages.value[idx] = {
|
|
||||||
...old,
|
|
||||||
id: resp.assistant_message_id,
|
|
||||||
content: '',
|
|
||||||
thinking: null,
|
|
||||||
status: 'pending',
|
|
||||||
error_message: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
streamingMessageId.value = resp.assistant_message_id
|
|
||||||
attachStream(resp.stream_url, resp.assistant_message_id - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteConversation(id: string) {
|
async function deleteConversation(id: string) {
|
||||||
await chatApi.deleteConversation(id)
|
await chatApi.deleteConversation(id)
|
||||||
conversations.value = conversations.value.filter((c) => c.id !== id)
|
conversations.value = conversations.value.filter((c) => c.id !== id)
|
||||||
if (currentConversation.value?.id === id) {
|
if (session.currentConversation.value?.id === id) {
|
||||||
closeCurrent()
|
session.closeCurrent()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCurrent() {
|
|
||||||
detachStream()
|
|
||||||
currentConversation.value = null
|
|
||||||
messages.value = []
|
|
||||||
streamingMessageId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachStream(streamUrl: string, sinceMessageID: number) {
|
|
||||||
detachStream()
|
|
||||||
streamHandle.value = startChatStream(streamUrl, sinceMessageID, {
|
|
||||||
onEvent: (ev) => applyStreamEvent(ev),
|
|
||||||
onClose: () => {
|
|
||||||
streamHandle.value = null
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
console.error('chat stream error', err)
|
|
||||||
const id = streamingMessageId.value
|
|
||||||
if (id != null) {
|
|
||||||
const msg = messages.value.find((m) => m.id === id)
|
|
||||||
if (msg && msg.status === 'pending') {
|
|
||||||
msg.status = 'error'
|
|
||||||
msg.error_message = err.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
streamingMessageId.value = null
|
|
||||||
streamHandle.value = null
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function detachStream() {
|
|
||||||
streamHandle.value?.stop()
|
|
||||||
streamHandle.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyStreamEvent(ev: ChatStreamEvent) {
|
|
||||||
const targetId = streamingMessageId.value
|
|
||||||
if (targetId == null) return
|
|
||||||
const msg = messages.value.find((m) => m.id === targetId)
|
|
||||||
if (!msg) return
|
|
||||||
switch (ev.event) {
|
|
||||||
case 'message_meta': {
|
|
||||||
const d = ev.data as MetaPayload
|
|
||||||
if (d.status) msg.status = d.status
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'text_delta': {
|
|
||||||
const d = ev.data as TextDeltaPayload
|
|
||||||
if (d.text) msg.content += d.text
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'thinking_delta': {
|
|
||||||
const d = ev.data as TextDeltaPayload
|
|
||||||
if (d.text) msg.thinking = (msg.thinking ?? '') + d.text
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'usage': {
|
|
||||||
const u = ev.data as UsagePayload
|
|
||||||
msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens
|
|
||||||
msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens
|
|
||||||
msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'done':
|
|
||||||
// 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。
|
|
||||||
// tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。
|
|
||||||
msg.status = 'ok'
|
|
||||||
streamingMessageId.value = null
|
|
||||||
break
|
|
||||||
case 'error': {
|
|
||||||
const d = ev.data as ErrorPayload
|
|
||||||
msg.status = 'error'
|
|
||||||
msg.error_message = d.message ?? d.code ?? 'stream error'
|
|
||||||
streamingMessageId.value = null
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conversations,
|
conversations,
|
||||||
currentConversation,
|
currentConversation: session.currentConversation,
|
||||||
messages,
|
messages: session.messages,
|
||||||
streamingMessageId,
|
streamingMessageId: session.streamingMessageId,
|
||||||
isStreaming,
|
isStreaming: session.isStreaming,
|
||||||
loadConversations,
|
loadConversations,
|
||||||
openConversation,
|
openConversation: session.openConversation,
|
||||||
loadOlderMessages,
|
loadOlderMessages: session.loadOlderMessages,
|
||||||
closeCurrent,
|
closeCurrent: session.closeCurrent,
|
||||||
sendMessage,
|
sendMessage: session.sendMessage,
|
||||||
cancelMessage,
|
cancelMessage: session.cancelMessage,
|
||||||
retryMessage,
|
retryMessage: session.retryMessage,
|
||||||
deleteConversation,
|
deleteConversation,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
projectsApi,
|
projectsApi,
|
||||||
|
type Artifact,
|
||||||
|
type ArtifactPhase,
|
||||||
type Phase,
|
type Phase,
|
||||||
type Prototype,
|
|
||||||
type Requirement,
|
type Requirement,
|
||||||
type Status,
|
type Status,
|
||||||
} from '@/api/projects'
|
} from '@/api/projects'
|
||||||
@@ -51,26 +52,29 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
await refresh(forSlug)
|
await refresh(forSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listPrototypes(forSlug: string, number: number): Promise<Prototype[]> {
|
async function listArtifacts(
|
||||||
const out = await projectsApi.listPrototypes(forSlug, number)
|
|
||||||
return out.prototypes
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPrototype(
|
|
||||||
forSlug: string,
|
forSlug: string,
|
||||||
number: number,
|
number: number,
|
||||||
body: { content: string; note?: string },
|
phase?: ArtifactPhase,
|
||||||
): Promise<Prototype> {
|
): Promise<Artifact[]> {
|
||||||
return await projectsApi.createPrototype(forSlug, number, body)
|
return await projectsApi.listArtifacts(forSlug, number, phase)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPrototype(
|
async function createArtifact(
|
||||||
forSlug: string,
|
forSlug: string,
|
||||||
number: number,
|
number: number,
|
||||||
|
body: { phase: ArtifactPhase; content: string; note?: string; source_message_id?: number },
|
||||||
|
): Promise<Artifact> {
|
||||||
|
return await projectsApi.createArtifact(forSlug, number, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getArtifact(
|
||||||
|
forSlug: string,
|
||||||
|
number: number,
|
||||||
|
phase: ArtifactPhase,
|
||||||
version: number,
|
version: number,
|
||||||
): Promise<Prototype> {
|
): Promise<Artifact> {
|
||||||
const out = await projectsApi.getPrototype(forSlug, number, version)
|
return await projectsApi.getArtifact(forSlug, number, phase, version)
|
||||||
return out.prototype
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -84,8 +88,8 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
changePhase,
|
changePhase,
|
||||||
close,
|
close,
|
||||||
reopen,
|
reopen,
|
||||||
listPrototypes,
|
listArtifacts,
|
||||||
createPrototype,
|
createArtifact,
|
||||||
getPrototype,
|
getArtifact,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,8 +46,13 @@ vi.mock('@/api/workspaces', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const routeMock = vi.hoisted(() => ({
|
||||||
|
params: { slug: 'p', wsSlug: 'w' },
|
||||||
|
query: {} as Record<string, string>,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => ({ params: { slug: 'p', wsSlug: 'w' } }),
|
useRoute: () => routeMock,
|
||||||
useRouter: () => ({ push: vi.fn() }),
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -55,6 +60,7 @@ import AcpSessionCreateView from './AcpSessionCreateView.vue'
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
routeMock.query = {}
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AcpSessionCreateView', () => {
|
describe('AcpSessionCreateView', () => {
|
||||||
@@ -63,4 +69,13 @@ describe('AcpSessionCreateView', () => {
|
|||||||
await new Promise((r) => setTimeout(r, 20))
|
await new Promise((r) => setTimeout(r, 20))
|
||||||
expect(wrapper.html()).toContain('New ACP Session')
|
expect(wrapper.html()).toContain('New ACP Session')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('prefills requirement/issue number from query', async () => {
|
||||||
|
routeMock.query = { requirement_number: '7', issue_number: '42' }
|
||||||
|
const wrapper = mount(AcpSessionCreateView)
|
||||||
|
await new Promise((r) => setTimeout(r, 20))
|
||||||
|
const inputs = wrapper.findAll('input[type="number"]')
|
||||||
|
expect((inputs[0].element as HTMLInputElement).value).toBe('42')
|
||||||
|
expect((inputs[1].element as HTMLInputElement).value).toBe('7')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ const submitting = ref(false)
|
|||||||
const errorMsg = ref<string | null>(null)
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 来自需求详情页等入口的预填(?requirement_number= / ?issue_number=)。
|
||||||
|
if (typeof route.query.requirement_number === 'string') {
|
||||||
|
form.value.requirement_number = route.query.requirement_number
|
||||||
|
}
|
||||||
|
if (typeof route.query.issue_number === 'string') {
|
||||||
|
form.value.issue_number = route.query.issue_number
|
||||||
|
}
|
||||||
await acpStore.listAgentKinds(false)
|
await acpStore.listAgentKinds(false)
|
||||||
if (wsStore.list.length === 0) {
|
if (wsStore.list.length === 0) {
|
||||||
await wsStore.fetchByProject(route.params.slug as string)
|
await wsStore.fetchByProject(route.params.slug as string)
|
||||||
|
|||||||
@@ -62,6 +62,18 @@
|
|||||||
in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }}
|
in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }}
|
||||||
<span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span>
|
<span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="canSave"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
size="tiny"
|
||||||
|
secondary
|
||||||
|
@click="emit('save', message.id)"
|
||||||
|
>
|
||||||
|
保存为产物
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,7 +86,15 @@ import MarkdownIt from 'markdown-it'
|
|||||||
import type { ChatMessage } from '@/api/chat'
|
import type { ChatMessage } from '@/api/chat'
|
||||||
|
|
||||||
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
||||||
const props = defineProps<{ message: ChatMessage }>()
|
// savable:仅需求阶段对话面板传 true,对成功的 assistant 回复显示「保存为产物」。
|
||||||
const emit = defineEmits<{ retry: [id: number] }>()
|
const props = defineProps<{ message: ChatMessage; savable?: boolean }>()
|
||||||
|
const emit = defineEmits<{ retry: [id: number]; save: [id: number] }>()
|
||||||
const rendered = computed(() => md.render(props.message.content))
|
const rendered = computed(() => md.render(props.message.content))
|
||||||
|
const canSave = computed(
|
||||||
|
() =>
|
||||||
|
props.savable === true &&
|
||||||
|
props.message.role === 'assistant' &&
|
||||||
|
props.message.status === 'ok' &&
|
||||||
|
props.message.content.trim() !== '',
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase flow bar -->
|
<!-- Phase flow bar(阶段跳转始终由用户确认触发) -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<template
|
<template
|
||||||
v-for="(phase, idx) in PHASES"
|
v-for="(phase, idx) in PHASES"
|
||||||
@@ -69,6 +69,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 左右布局:左=需求信息+当前阶段产物,右=按阶段切换的 AI 工作面板 -->
|
||||||
|
<div class="flex gap-6 items-start">
|
||||||
|
<div class="w-[45%] shrink-0 space-y-6">
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<NCard title="描述">
|
<NCard title="描述">
|
||||||
<p
|
<p
|
||||||
@@ -85,98 +88,15 @@
|
|||||||
</p>
|
</p>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<!-- Prototypes -->
|
<!-- 当前阶段产物(仅 AI 对话三阶段显示) -->
|
||||||
<NCard title="原型">
|
<ArtifactPanel
|
||||||
<div class="flex gap-4">
|
v-if="chatPhase"
|
||||||
<!-- Version list -->
|
ref="artifactPanelRef"
|
||||||
<div class="w-56 shrink-0">
|
:slug="slug"
|
||||||
<NList
|
:number="number"
|
||||||
v-if="prototypes.length > 0"
|
:phase="chatPhase"
|
||||||
:show-divider="false"
|
:readonly="data.requirement.status !== 'open'"
|
||||||
>
|
|
||||||
<NListItem
|
|
||||||
v-for="p in prototypes"
|
|
||||||
:key="p.id"
|
|
||||||
>
|
|
||||||
<NThing
|
|
||||||
class="cursor-pointer"
|
|
||||||
@click="onSelectPrototype(p.version)"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<span
|
|
||||||
class="text-sm font-medium"
|
|
||||||
:class="selectedVersion === p.version ? 'text-blue-600' : ''"
|
|
||||||
>v{{ p.version }}</span>
|
|
||||||
</template>
|
|
||||||
<template #header-extra>
|
|
||||||
<span class="text-xs text-gray-400">
|
|
||||||
{{ new Date(p.created_at).toLocaleString() }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<span
|
|
||||||
v-if="p.note"
|
|
||||||
class="text-xs text-gray-500"
|
|
||||||
>{{ p.note }}</span>
|
|
||||||
</NThing>
|
|
||||||
</NListItem>
|
|
||||||
</NList>
|
|
||||||
<NEmpty
|
|
||||||
v-else
|
|
||||||
description="暂无原型版本"
|
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selected content -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p
|
|
||||||
v-if="selectedPrototype"
|
|
||||||
class="text-gray-700 whitespace-pre-wrap text-sm"
|
|
||||||
>
|
|
||||||
{{ selectedPrototype.content }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-else
|
|
||||||
class="text-gray-400 text-sm"
|
|
||||||
>
|
|
||||||
选择左侧版本以查看内容
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New version form -->
|
|
||||||
<NDivider />
|
|
||||||
<div
|
|
||||||
v-if="data.requirement.status === 'open'"
|
|
||||||
class="space-y-2"
|
|
||||||
>
|
|
||||||
<NInput
|
|
||||||
v-model:value="newContent"
|
|
||||||
type="textarea"
|
|
||||||
placeholder="原型内容"
|
|
||||||
:autosize="{ minRows: 4, maxRows: 12 }"
|
|
||||||
/>
|
|
||||||
<NInput
|
|
||||||
v-model:value="newNote"
|
|
||||||
placeholder="备注(可选)"
|
|
||||||
/>
|
|
||||||
<NButton
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
:loading="submitting"
|
|
||||||
:disabled="!newContent.trim()"
|
|
||||||
@click="onSubmitPrototype"
|
|
||||||
>
|
|
||||||
提交新版本
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-else
|
|
||||||
class="text-gray-400 text-sm"
|
|
||||||
>
|
|
||||||
需求已关闭,无法提交新版本
|
|
||||||
</p>
|
|
||||||
</NCard>
|
|
||||||
|
|
||||||
<!-- Workspace card -->
|
<!-- Workspace card -->
|
||||||
<NCard title="工作区">
|
<NCard title="工作区">
|
||||||
@@ -222,6 +142,34 @@
|
|||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 右栏:v-if 切换保证阶段变化时旧面板卸载(自动断流) -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<RequirementChatPanel
|
||||||
|
v-if="chatPhase"
|
||||||
|
:slug="slug"
|
||||||
|
:requirement="data.requirement"
|
||||||
|
:phase="chatPhase"
|
||||||
|
:prev-artifact="prevArtifact"
|
||||||
|
:readonly="data.requirement.status !== 'open'"
|
||||||
|
class="min-h-[70vh]"
|
||||||
|
@artifact-saved="onArtifactSaved"
|
||||||
|
/>
|
||||||
|
<RequirementAcpPanel
|
||||||
|
v-else-if="
|
||||||
|
data.requirement.phase === 'implementing' || data.requirement.phase === 'reviewing'
|
||||||
|
"
|
||||||
|
:slug="slug"
|
||||||
|
:requirement="data.requirement"
|
||||||
|
/>
|
||||||
|
<RequirementSummaryPanel
|
||||||
|
v-else
|
||||||
|
:slug="slug"
|
||||||
|
:number="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="!loading"
|
v-else-if="!loading"
|
||||||
class="text-gray-400 text-sm py-8 text-center"
|
class="text-gray-400 text-sm py-8 text-center"
|
||||||
@@ -233,7 +181,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useDialog } from 'naive-ui'
|
import { useDialog } from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
@@ -246,26 +194,31 @@ import {
|
|||||||
NListItem,
|
NListItem,
|
||||||
NThing,
|
NThing,
|
||||||
NEmpty,
|
NEmpty,
|
||||||
NInput,
|
|
||||||
NDivider,
|
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import { projectsApi } from '@/api/projects'
|
import { projectsApi } from '@/api/projects'
|
||||||
import { useRequirementsStore } from '@/stores/requirements'
|
import { useRequirementsStore } from '@/stores/requirements'
|
||||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||||
import type { Phase, Prototype, RequirementDetail } from '@/api/projects'
|
import { PREV_ARTIFACT_PHASE } from '@/constants/requirementPhasePrompts'
|
||||||
|
import ArtifactPanel from './components/ArtifactPanel.vue'
|
||||||
|
import RequirementChatPanel from './components/RequirementChatPanel.vue'
|
||||||
|
import RequirementAcpPanel from './components/RequirementAcpPanel.vue'
|
||||||
|
import RequirementSummaryPanel from './components/RequirementSummaryPanel.vue'
|
||||||
|
import type { Artifact, ArtifactPhase, Phase, RequirementDetail } from '@/api/projects'
|
||||||
|
|
||||||
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
|
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
|
||||||
|
|
||||||
const PHASE_LABELS: Record<Phase, string> = {
|
const PHASE_LABELS: Record<Phase, string> = {
|
||||||
planning: '规划中',
|
planning: '规划中',
|
||||||
prototyping: '原型设计',
|
prototyping: '原型设计',
|
||||||
auditing: '审核中',
|
auditing: '评审中',
|
||||||
implementing: '实施中',
|
implementing: '实施中',
|
||||||
reviewing: '评审中',
|
reviewing: '验收中',
|
||||||
done: '已完成',
|
done: '已完成',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHAT_PHASES: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -278,20 +231,18 @@ const number = Number(route.params.number)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const toggling = ref(false)
|
const toggling = ref(false)
|
||||||
const data = ref<RequirementDetail | null>(null)
|
const data = ref<RequirementDetail | null>(null)
|
||||||
|
const artifactPanelRef = ref<InstanceType<typeof ArtifactPanel> | null>(null)
|
||||||
const prototypes = ref<Prototype[]>([])
|
const prevArtifact = ref<Artifact | null>(null)
|
||||||
const selectedVersion = ref<number | null>(null)
|
|
||||||
const newContent = ref('')
|
|
||||||
const newNote = ref('')
|
|
||||||
const submitting = ref(false)
|
|
||||||
|
|
||||||
const workspaceOptions = computed(() =>
|
const workspaceOptions = computed(() =>
|
||||||
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
|
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedPrototype = computed(() =>
|
// 当前阶段属于 AI 对话三阶段时返回该阶段,否则 null。
|
||||||
prototypes.value.find((p) => p.version === selectedVersion.value) ?? null,
|
const chatPhase = computed<ArtifactPhase | null>(() => {
|
||||||
)
|
const p = data.value?.requirement.phase
|
||||||
|
return p && (CHAT_PHASES as Phase[]).includes(p) ? (p as ArtifactPhase) : null
|
||||||
|
})
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -301,44 +252,25 @@ async function fetchData() {
|
|||||||
wsStore.fetchByProject(slug),
|
wsStore.fetchByProject(slug),
|
||||||
])
|
])
|
||||||
data.value = detail
|
data.value = detail
|
||||||
await refreshPrototypes()
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPrototypes() {
|
// 上一阶段产物最新版:注入新会话 system prompt(planning 无上一阶段)。
|
||||||
prototypes.value = await store.listPrototypes(slug, number)
|
async function loadPrevArtifact() {
|
||||||
// 默认选中最新版本
|
prevArtifact.value = null
|
||||||
if (prototypes.value.length > 0) {
|
if (!chatPhase.value) return
|
||||||
const latest = prototypes.value[prototypes.value.length - 1]
|
const prevPhase = PREV_ARTIFACT_PHASE[chatPhase.value]
|
||||||
if (selectedVersion.value === null || !selectedPrototype.value) {
|
if (!prevPhase) return
|
||||||
selectedVersion.value = latest.version
|
const list = await store.listArtifacts(slug, number, prevPhase)
|
||||||
}
|
prevArtifact.value = list.length > 0 ? list[list.length - 1] : null
|
||||||
} else {
|
|
||||||
selectedVersion.value = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectPrototype(version: number) {
|
watch(chatPhase, loadPrevArtifact)
|
||||||
selectedVersion.value = version
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmitPrototype() {
|
function onArtifactSaved() {
|
||||||
if (!newContent.value.trim()) return
|
artifactPanelRef.value?.refresh()
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
const created = await store.createPrototype(slug, number, {
|
|
||||||
content: newContent.value,
|
|
||||||
note: newNote.value || undefined,
|
|
||||||
})
|
|
||||||
newContent.value = ''
|
|
||||||
newNote.value = ''
|
|
||||||
selectedVersion.value = created.version
|
|
||||||
await refreshPrototypes()
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onWorkspaceChange(v: string | null) {
|
async function onWorkspaceChange(v: string | null) {
|
||||||
@@ -384,5 +316,8 @@ async function onReopen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchData)
|
onMounted(async () => {
|
||||||
|
await fetchData()
|
||||||
|
await loadPrevArtifact()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<NCard :title="`${PHASE_TITLE[phase]}产物`">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<!-- 版本列表 -->
|
||||||
|
<div class="w-44 shrink-0">
|
||||||
|
<NList
|
||||||
|
v-if="artifacts.length > 0"
|
||||||
|
:show-divider="false"
|
||||||
|
>
|
||||||
|
<NListItem
|
||||||
|
v-for="a in artifacts"
|
||||||
|
:key="a.id"
|
||||||
|
>
|
||||||
|
<NThing
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="selectedVersion = a.version"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="selectedVersion === a.version ? 'text-blue-600' : ''"
|
||||||
|
>v{{ a.version }}</span>
|
||||||
|
<NTag
|
||||||
|
v-if="a.source_message_id"
|
||||||
|
size="tiny"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
AI
|
||||||
|
</NTag>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ new Date(a.created_at).toLocaleDateString() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="a.note"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>{{ a.note }}</span>
|
||||||
|
</NThing>
|
||||||
|
</NListItem>
|
||||||
|
</NList>
|
||||||
|
<NEmpty
|
||||||
|
v-else
|
||||||
|
description="暂无产物版本"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中版本内容(markdown 渲染) -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-if="selectedArtifact"
|
||||||
|
class="prose prose-sm max-w-none"
|
||||||
|
v-html="rendered"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
选择左侧版本以查看内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手动提交新版本 -->
|
||||||
|
<NDivider />
|
||||||
|
<div
|
||||||
|
v-if="!readonly"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="newContent"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="产物内容(Markdown)"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 10 }"
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="newNote"
|
||||||
|
placeholder="备注(可选)"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!newContent.trim()"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
提交新版本
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
需求已关闭,无法提交新版本
|
||||||
|
</p>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NDivider,
|
||||||
|
NEmpty,
|
||||||
|
NInput,
|
||||||
|
NList,
|
||||||
|
NListItem,
|
||||||
|
NTag,
|
||||||
|
NThing,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
|
import type { Artifact, ArtifactPhase } from '@/api/projects'
|
||||||
|
import { useRequirementsStore } from '@/stores/requirements'
|
||||||
|
|
||||||
|
const PHASE_TITLE: Record<ArtifactPhase, string> = {
|
||||||
|
planning: '规划',
|
||||||
|
prototyping: '原型',
|
||||||
|
auditing: '评审',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
slug: string
|
||||||
|
number: number
|
||||||
|
phase: ArtifactPhase
|
||||||
|
readonly?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useRequirementsStore()
|
||||||
|
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
||||||
|
|
||||||
|
const artifacts = ref<Artifact[]>([])
|
||||||
|
const selectedVersion = ref<number | null>(null)
|
||||||
|
const newContent = ref('')
|
||||||
|
const newNote = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const selectedArtifact = computed(
|
||||||
|
() => artifacts.value.find((a) => a.version === selectedVersion.value) ?? null,
|
||||||
|
)
|
||||||
|
const rendered = computed(() =>
|
||||||
|
selectedArtifact.value ? md.render(selectedArtifact.value.content) : '',
|
||||||
|
)
|
||||||
|
|
||||||
|
// latest 暴露给父组件:创建会话时把当前阶段或上一阶段最新版注入 system prompt。
|
||||||
|
const latest = computed(() =>
|
||||||
|
artifacts.value.length > 0 ? artifacts.value[artifacts.value.length - 1] : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
artifacts.value = await store.listArtifacts(props.slug, props.number, props.phase)
|
||||||
|
if (artifacts.value.length > 0) {
|
||||||
|
if (selectedVersion.value === null || !selectedArtifact.value) {
|
||||||
|
selectedVersion.value = artifacts.value[artifacts.value.length - 1].version
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedVersion.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!newContent.value.trim()) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const created = await store.createArtifact(props.slug, props.number, {
|
||||||
|
phase: props.phase,
|
||||||
|
content: newContent.value,
|
||||||
|
note: newNote.value || undefined,
|
||||||
|
})
|
||||||
|
newContent.value = ''
|
||||||
|
newNote.value = ''
|
||||||
|
selectedVersion.value = created.version
|
||||||
|
await refresh()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段切换时重载该阶段产物(选中状态重置)
|
||||||
|
watch(
|
||||||
|
() => props.phase,
|
||||||
|
() => {
|
||||||
|
selectedVersion.value = null
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(refresh)
|
||||||
|
|
||||||
|
defineExpose({ refresh, latest })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<NCard :title="`ACP 开发会话 · ${phaseLabel}`">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">关联本需求的 agent 会话</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
:loading="loading"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</NButton>
|
||||||
|
<NTooltip :disabled="!!workspaceSlug">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!workspaceSlug"
|
||||||
|
@click="goCreate"
|
||||||
|
>
|
||||||
|
发起{{ phaseLabel }}会话
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
需先在左侧「工作区」卡片关联 workspace
|
||||||
|
</NTooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NList
|
||||||
|
v-if="sessions.length > 0"
|
||||||
|
:show-divider="false"
|
||||||
|
>
|
||||||
|
<NListItem
|
||||||
|
v-for="s in sessions"
|
||||||
|
:key="s.id"
|
||||||
|
>
|
||||||
|
<NThing
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="goDetail(s)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span class="font-mono text-sm">{{ s.branch }}</span>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<SessionStatusBadge :status="s.status" />
|
||||||
|
</template>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ new Date(s.started_at).toLocaleString('zh-CN', { hour12: false }) }}
|
||||||
|
<template v-if="s.last_error"> · {{ s.last_error }}</template>
|
||||||
|
</span>
|
||||||
|
</NThing>
|
||||||
|
</NListItem>
|
||||||
|
</NList>
|
||||||
|
<NEmpty
|
||||||
|
v-else
|
||||||
|
description="暂无关联会话"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { NButton, NCard, NEmpty, NList, NListItem, NThing, NTooltip } from 'naive-ui'
|
||||||
|
|
||||||
|
import { acpApi, type AcpSession } from '@/api/acp'
|
||||||
|
import type { Requirement } from '@/api/projects'
|
||||||
|
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||||
|
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
slug: string
|
||||||
|
requirement: Requirement
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const wsStore = useWorkspacesStore()
|
||||||
|
|
||||||
|
const sessions = ref<AcpSession[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const phaseLabel = computed(() =>
|
||||||
|
props.requirement.phase === 'reviewing' ? '验收' : '实施',
|
||||||
|
)
|
||||||
|
|
||||||
|
// requirement.workspace_id → workspace slug(路由需要 wsSlug)。
|
||||||
|
const workspaceSlug = computed(() => {
|
||||||
|
const wsID = props.requirement.workspace_id
|
||||||
|
if (!wsID) return null
|
||||||
|
return wsStore.list.find((w) => w.id === wsID)?.slug ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
sessions.value = await acpApi.sessions.list({ requirement_id: props.requirement.id })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCreate() {
|
||||||
|
if (!workspaceSlug.value) return
|
||||||
|
router.push({
|
||||||
|
name: 'acp-sessions-new',
|
||||||
|
params: { slug: props.slug, wsSlug: workspaceSlug.value },
|
||||||
|
query: { requirement_number: String(props.requirement.number) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(s: AcpSession) {
|
||||||
|
if (!workspaceSlug.value) return
|
||||||
|
router.push({
|
||||||
|
name: 'acp-sessions-detail',
|
||||||
|
params: { slug: props.slug, wsSlug: workspaceSlug.value, sessionId: s.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<NCard
|
||||||
|
:title="`AI 对话 · ${PHASE_TITLE[phase]}`"
|
||||||
|
class="flex flex-col h-full"
|
||||||
|
content-class="flex flex-col flex-1 min-h-0"
|
||||||
|
>
|
||||||
|
<!-- 会话选择条 -->
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<NSelect
|
||||||
|
:value="session.currentConversation.value?.id ?? null"
|
||||||
|
:options="conversationOptions"
|
||||||
|
placeholder="选择历史会话"
|
||||||
|
size="small"
|
||||||
|
class="flex-1"
|
||||||
|
:disabled="creating"
|
||||||
|
@update:value="onSelectConversation"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
@click="openCreateForm"
|
||||||
|
>
|
||||||
|
新建会话
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新建会话表单 -->
|
||||||
|
<div
|
||||||
|
v-if="showCreateForm"
|
||||||
|
class="space-y-2 mb-3 border rounded p-3"
|
||||||
|
>
|
||||||
|
<ModelSelector v-model:model-value="newModelId" />
|
||||||
|
<NInput
|
||||||
|
v-model:value="newSystemPrompt"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="system prompt(已自动注入需求上下文,可修改)"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 10 }"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="creating"
|
||||||
|
:disabled="!newModelId"
|
||||||
|
@click="onCreateConversation"
|
||||||
|
>
|
||||||
|
创建并开始
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息区 -->
|
||||||
|
<div
|
||||||
|
v-if="session.currentConversation.value"
|
||||||
|
ref="scrollEl"
|
||||||
|
class="flex-1 min-h-0 overflow-y-auto space-y-3 py-2"
|
||||||
|
>
|
||||||
|
<MessageBubble
|
||||||
|
v-for="m in session.messages.value"
|
||||||
|
:key="m.id"
|
||||||
|
:message="m"
|
||||||
|
:savable="!readonly"
|
||||||
|
@retry="session.retryMessage"
|
||||||
|
@save="onSaveMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex-1 flex items-center justify-center text-neutral-400 text-sm"
|
||||||
|
>
|
||||||
|
选择历史会话,或新建会话开始与 AI 讨论本阶段内容
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入区 -->
|
||||||
|
<div
|
||||||
|
v-if="session.currentConversation.value"
|
||||||
|
class="border-t pt-3 flex gap-2"
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="draft"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||||
|
placeholder="发送消息(Enter 发送,Shift+Enter 换行)"
|
||||||
|
:disabled="session.isStreaming.value"
|
||||||
|
@keydown.enter.exact.prevent="send"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
v-if="!session.isStreaming.value"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!draft.trim()"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
v-else
|
||||||
|
type="warning"
|
||||||
|
@click="session.cancelMessage"
|
||||||
|
>
|
||||||
|
中断
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SaveArtifactModal
|
||||||
|
v-model:show="showSaveModal"
|
||||||
|
:slug="slug"
|
||||||
|
:number="requirement.number"
|
||||||
|
:phase="phase"
|
||||||
|
:source-message="saveSource"
|
||||||
|
@saved="emit('artifact-saved')"
|
||||||
|
/>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { NButton, NCard, NInput, NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
|
||||||
|
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
|
||||||
|
import { useConversationSession } from '@/composables/useConversationSession'
|
||||||
|
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
|
||||||
|
import MessageBubble from '@/views/chat/components/MessageBubble.vue'
|
||||||
|
import ModelSelector from '@/views/chat/components/ModelSelector.vue'
|
||||||
|
import SaveArtifactModal from './SaveArtifactModal.vue'
|
||||||
|
|
||||||
|
const PHASE_TITLE: Record<ArtifactPhase, string> = {
|
||||||
|
planning: '规划',
|
||||||
|
prototyping: '原型设计',
|
||||||
|
auditing: '评审',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
slug: string
|
||||||
|
requirement: Requirement
|
||||||
|
phase: ArtifactPhase
|
||||||
|
// 上一阶段产物最新版(planning 无),注入新会话 system prompt。
|
||||||
|
prevArtifact?: Artifact | null
|
||||||
|
readonly?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'artifact-saved': [] }>()
|
||||||
|
|
||||||
|
// 自有会话实例,与全局 /chat 页完全隔离。
|
||||||
|
const session = useConversationSession()
|
||||||
|
|
||||||
|
const conversations = ref<Conversation[]>([])
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newModelId = ref<string | undefined>(undefined)
|
||||||
|
const newSystemPrompt = ref('')
|
||||||
|
const creating = ref(false)
|
||||||
|
const draft = ref('')
|
||||||
|
const scrollEl = ref<HTMLElement | null>(null)
|
||||||
|
const showSaveModal = ref(false)
|
||||||
|
const saveSource = ref<ChatMessage | null>(null)
|
||||||
|
|
||||||
|
const conversationOptions = computed(() =>
|
||||||
|
conversations.value.map((c) => ({
|
||||||
|
label: `${c.title || '(未命名)'} · ${new Date(c.updated_at).toLocaleString('zh-CN', { hour12: false })}`,
|
||||||
|
value: c.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadConversations() {
|
||||||
|
conversations.value = await chatApi.listConversations({
|
||||||
|
requirement_id: props.requirement.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateForm() {
|
||||||
|
newSystemPrompt.value = buildRequirementSystemPrompt(
|
||||||
|
props.phase,
|
||||||
|
props.requirement,
|
||||||
|
props.prevArtifact,
|
||||||
|
)
|
||||||
|
showCreateForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateConversation() {
|
||||||
|
if (!newModelId.value) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const conv = await chatApi.createConversation({
|
||||||
|
model_id: newModelId.value,
|
||||||
|
system_prompt: newSystemPrompt.value,
|
||||||
|
project_id: props.requirement.project_id,
|
||||||
|
requirement_id: props.requirement.id,
|
||||||
|
})
|
||||||
|
showCreateForm.value = false
|
||||||
|
await loadConversations()
|
||||||
|
await session.openConversation(conv.id)
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSelectConversation(id: string | null) {
|
||||||
|
if (!id) return
|
||||||
|
await session.openConversation(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!draft.value.trim() || session.isStreaming.value) return
|
||||||
|
const body = draft.value
|
||||||
|
draft.value = ''
|
||||||
|
await session.sendMessage(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaveMessage(id: number) {
|
||||||
|
const msg = session.messages.value.find((m) => m.id === id)
|
||||||
|
if (!msg) return
|
||||||
|
saveSource.value = msg
|
||||||
|
showSaveModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式输出时自动滚到底部(同 ChatDetailView 的策略)。
|
||||||
|
watch(
|
||||||
|
() => [session.messages.value.length, session.messages.value.at(-1)?.content?.length ?? 0],
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = scrollEl.value
|
||||||
|
if (el) el.scrollTo(0, el.scrollHeight)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 阶段切换(父组件未卸载本组件时)重置会话面板状态。
|
||||||
|
watch(
|
||||||
|
() => props.phase,
|
||||||
|
() => {
|
||||||
|
session.closeCurrent()
|
||||||
|
showCreateForm.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(loadConversations)
|
||||||
|
onUnmounted(() => session.closeCurrent())
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<NCard title="产物汇总">
|
||||||
|
<NSpin :show="loading">
|
||||||
|
<div
|
||||||
|
v-if="grouped.length > 0"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="g in grouped"
|
||||||
|
:key="g.phase"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">
|
||||||
|
{{ PHASE_TITLE[g.phase] }}({{ g.items.length }} 个版本)
|
||||||
|
</h3>
|
||||||
|
<NList :show-divider="false">
|
||||||
|
<NListItem
|
||||||
|
v-for="a in g.items"
|
||||||
|
:key="a.id"
|
||||||
|
>
|
||||||
|
<NThing
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="selected = selected?.id === a.id ? null : a"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="selected?.id === a.id ? 'text-blue-600' : ''"
|
||||||
|
>v{{ a.version }}</span>
|
||||||
|
<NTag
|
||||||
|
v-if="a.source_message_id"
|
||||||
|
size="tiny"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
AI
|
||||||
|
</NTag>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ new Date(a.created_at).toLocaleString('zh-CN', { hour12: false }) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="a.note"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>{{ a.note }}</span>
|
||||||
|
</NThing>
|
||||||
|
</NListItem>
|
||||||
|
</NList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="selected">
|
||||||
|
<NDivider />
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
class="prose prose-sm max-w-none"
|
||||||
|
v-html="rendered"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<NEmpty
|
||||||
|
v-else-if="!loading"
|
||||||
|
description="暂无任何阶段产物"
|
||||||
|
/>
|
||||||
|
</NSpin>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { NCard, NDivider, NEmpty, NList, NListItem, NSpin, NTag, NThing } from 'naive-ui'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
|
import type { Artifact, ArtifactPhase } from '@/api/projects'
|
||||||
|
import { useRequirementsStore } from '@/stores/requirements'
|
||||||
|
|
||||||
|
const PHASE_TITLE: Record<ArtifactPhase, string> = {
|
||||||
|
planning: '规划',
|
||||||
|
prototyping: '原型',
|
||||||
|
auditing: '评审',
|
||||||
|
}
|
||||||
|
const PHASE_ORDER: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
slug: string
|
||||||
|
number: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useRequirementsStore()
|
||||||
|
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
||||||
|
|
||||||
|
const artifacts = ref<Artifact[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const selected = ref<Artifact | null>(null)
|
||||||
|
|
||||||
|
const grouped = computed(() =>
|
||||||
|
PHASE_ORDER.map((phase) => ({
|
||||||
|
phase,
|
||||||
|
items: artifacts.value.filter((a) => a.phase === phase),
|
||||||
|
})).filter((g) => g.items.length > 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const rendered = computed(() => (selected.value ? md.render(selected.value.content) : ''))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
artifacts.value = await store.listArtifacts(props.slug, props.number)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
:title="`保存为${PHASE_TITLE[phase]}产物`"
|
||||||
|
class="max-w-4xl"
|
||||||
|
@update:show="(v: boolean) => emit('update:show', v)"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<NTabs type="segment">
|
||||||
|
<NTabPane
|
||||||
|
name="edit"
|
||||||
|
tab="编辑"
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="content"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="产物内容(Markdown,可在保存前修改)"
|
||||||
|
:autosize="{ minRows: 12, maxRows: 24 }"
|
||||||
|
/>
|
||||||
|
</NTabPane>
|
||||||
|
<NTabPane
|
||||||
|
name="preview"
|
||||||
|
tab="预览"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
class="prose prose-sm max-w-none max-h-[60vh] overflow-y-auto border rounded p-3"
|
||||||
|
v-html="rendered"
|
||||||
|
/>
|
||||||
|
</NTabPane>
|
||||||
|
</NTabs>
|
||||||
|
<NInput
|
||||||
|
v-model:value="note"
|
||||||
|
placeholder="版本备注(可选)"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NButton @click="emit('update:show', false)">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!content.trim()"
|
||||||
|
@click="onSave"
|
||||||
|
>
|
||||||
|
保存为新版本
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { NButton, NInput, NModal, NTabPane, NTabs, useMessage } from 'naive-ui'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
|
import type { ArtifactPhase } from '@/api/projects'
|
||||||
|
import type { ChatMessage } from '@/api/chat'
|
||||||
|
import { useRequirementsStore } from '@/stores/requirements'
|
||||||
|
|
||||||
|
const PHASE_TITLE: Record<ArtifactPhase, string> = {
|
||||||
|
planning: '规划',
|
||||||
|
prototyping: '原型',
|
||||||
|
auditing: '评审',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
slug: string
|
||||||
|
number: number
|
||||||
|
phase: ArtifactPhase
|
||||||
|
sourceMessage: ChatMessage | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:show': [v: boolean]; saved: [] }>()
|
||||||
|
|
||||||
|
const store = useRequirementsStore()
|
||||||
|
const message = useMessage()
|
||||||
|
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
||||||
|
|
||||||
|
const content = ref('')
|
||||||
|
const note = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const rendered = computed(() => md.render(content.value))
|
||||||
|
|
||||||
|
// 每次打开时从来源消息初始化可编辑内容。
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
content.value = props.sourceMessage?.content ?? ''
|
||||||
|
note.value = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!content.value.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await store.createArtifact(props.slug, props.number, {
|
||||||
|
phase: props.phase,
|
||||||
|
content: content.value,
|
||||||
|
note: note.value || undefined,
|
||||||
|
source_message_id: props.sourceMessage?.id,
|
||||||
|
})
|
||||||
|
message.success('已保存为新版本')
|
||||||
|
emit('update:show', false)
|
||||||
|
emit('saved')
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user