主流程

This commit is contained in:
2026-06-10 17:53:09 +08:00
parent c6f239f424
commit b20435c027
66 changed files with 2779 additions and 1181 deletions
+2 -2
View File
@@ -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) {
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")
}
func (f *fakeAgentKindRepo) ListAllSessions(context.Context) ([]*acp.Session, error) {
func (f *fakeAgentKindRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Session, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
+8 -1
View File
@@ -120,10 +120,17 @@ type AgentKindService interface {
type SessionService interface {
Create(ctx context.Context, c Caller, in CreateSessionInput) (*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
}
// SessionListFilter 控制 sessions 列表的过滤维度。
// All 仅 admin 可用(查看全部用户);RequirementID nil 表示不按需求过滤。
type SessionListFilter struct {
All bool
RequirementID *uuid.UUID
}
// CreateSessionInput 是创建 session 的入参。
// branch / issue_number / requirement_number 三选一或全空(spec §8.1)。
type CreateSessionInput struct {
+10 -2
View File
@@ -300,8 +300,16 @@ func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err)
return
}
all := r.URL.Query().Get("all") == "true"
list, err := h.sessSvc.List(r.Context(), c, all)
f := SessionListFilter{All: r.URL.Query().Get("all") == "true"}
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 {
writeErr(w, r, err)
return
+2
View File
@@ -23,6 +23,7 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id,
pid, exit_code, last_error, started_at, ended_at
FROM acp_sessions
WHERE user_id = $1
AND ($2::uuid IS NULL OR requirement_id = $2)
ORDER BY started_at DESC;
-- 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,
pid, exit_code, last_error, started_at, ended_at
FROM acp_sessions
WHERE ($1::uuid IS NULL OR requirement_id = $1)
ORDER BY started_at DESC;
-- name: UpdateSessionRunning :exec
+2 -2
View File
@@ -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) {
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")
}
func (f *fakeEventRepo) ListAllSessions(context.Context) ([]*acp.Session, error) {
func (f *fakeEventRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Session, error) {
panic("n/a")
}
func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
+10 -6
View File
@@ -31,8 +31,9 @@ type Repository interface {
// Session
InsertSession(ctx context.Context, s *Session) (*Session, error)
GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error)
ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*Session, error)
ListAllSessions(ctx context.Context) ([]*Session, error)
// requirementID 非 nil 时仅返回关联该需求的 sessions。
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
UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) 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
}
func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*Session, error) {
rows, err := r.q.ListSessionsByUser(ctx, toPgUUID(userID))
func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) {
rows, err := r.q.ListSessionsByUser(ctx, acpsqlc.ListSessionsByUserParams{
UserID: toPgUUID(userID),
Column2: toPgUUIDPtr(requirementID),
})
if err != nil {
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
}
func (r *pgRepo) ListAllSessions(ctx context.Context) ([]*Session, error) {
rows, err := r.q.ListAllSessions(ctx)
func (r *pgRepo) ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error) {
rows, err := r.q.ListAllSessions(ctx, toPgUUIDPtr(requirementID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list all sessions")
}
+5 -5
View File
@@ -335,14 +335,14 @@ func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Sess
return sess, nil
}
func (s *sessionService) List(ctx context.Context, c Caller, all bool) ([]*Session, error) {
if all && !c.IsAdmin {
func (s *sessionService) List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error) {
if f.All && !c.IsAdmin {
return nil, errs.New(errs.CodeForbidden, "admin only")
}
if all {
return s.repo.ListAllSessions(ctx)
if f.All {
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 {
+58 -3
View File
@@ -212,10 +212,33 @@ func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { re
func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
return 0, nil
}
func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, _ uuid.UUID) ([]*acp.Session, error) {
return nil, nil
func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, userID uuid.UUID, reqID *uuid.UUID) ([]*acp.Session, error) {
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 {
return nil
}
@@ -440,3 +463,35 @@ func (f *fakeAcpRepo) ExpirePendingPermissionRequestsBySession(context.Context,
func (f *fakeAcpRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
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)
}
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
+11 -4
View File
@@ -154,11 +154,12 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id,
branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at
FROM acp_sessions
WHERE ($1::uuid IS NULL OR requirement_id = $1)
ORDER BY started_at DESC
`
func (q *Queries) ListAllSessions(ctx context.Context) ([]AcpSession, error) {
rows, err := q.db.Query(ctx, listAllSessions)
func (q *Queries) ListAllSessions(ctx context.Context, dollar_1 pgtype.UUID) ([]AcpSession, error) {
rows, err := q.db.Query(ctx, listAllSessions, dollar_1)
if err != nil {
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
FROM acp_sessions
WHERE user_id = $1
AND ($2::uuid IS NULL OR requirement_id = $2)
ORDER BY started_at DESC
`
func (q *Queries) ListSessionsByUser(ctx context.Context, userID pgtype.UUID) ([]AcpSession, error) {
rows, err := q.db.Query(ctx, listSessionsByUser, userID)
type ListSessionsByUserParams struct {
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 {
return nil, err
}
+3 -3
View File
@@ -91,7 +91,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
projectSvc := project.NewProjectService(projectRepo, wrappedAudit)
requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit)
issueSvc := project.NewIssueService(projectRepo, wrappedAudit)
prototypeSvc := project.NewPrototypeService(projectRepo, wrappedAudit)
artifactSvc := project.NewArtifactService(projectRepo, wrappedAudit)
notifyRepo := notify.NewPostgresRepository(pool)
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 同名同签名)。
user.NewHandler(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 模块装配 =====
// 依赖图:
@@ -263,7 +263,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
Projects: projectSvc,
Reqs: requirementSvc,
Issues: issueSvc,
Prototypes: prototypeSvc,
Artifacts: artifactSvc,
Workspaces: wsSvc,
Templates: chatTplSvc,
Messages: chatMsgSvc,
+4 -4
View File
@@ -158,7 +158,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(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")
require.NoError(t, err)
@@ -167,7 +167,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
r := chi.NewRouter()
r.Use(middleware.RequestID)
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)
t.Cleanup(srv.Close)
@@ -326,7 +326,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(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")
require.NoError(t, err)
@@ -368,7 +368,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
r := chi.NewRouter()
r.Use(middleware.RequestID)
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)
srv := httptest.NewServer(r)
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
+3
View File
@@ -103,6 +103,7 @@ type Conversation struct {
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
RequirementID *uuid.UUID
ModelID uuid.UUID
SystemPrompt string
Title *string
@@ -187,6 +188,7 @@ type CreateConversationInput struct {
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
RequirementID *uuid.UUID
}
// ConversationFilter 列表过滤参数。
@@ -194,6 +196,7 @@ type ConversationFilter struct {
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
RequirementID *uuid.UUID
TitleQuery string
BeforeID *uuid.UUID // cursor
Limit int
+3
View File
@@ -16,6 +16,7 @@ type CreateConversationReq struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_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}.
@@ -99,6 +100,7 @@ type ConversationDTO struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
IssueID *uuid.UUID `json:"issue_id,omitempty"`
RequirementID *uuid.UUID `json:"requirement_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -232,6 +234,7 @@ func toConversationDTO(c *Conversation) ConversationDTO {
ProjectID: c.ProjectID,
WorkspaceID: c.WorkspaceID,
IssueID: c.IssueID,
RequirementID: c.RequirementID,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
+6
View File
@@ -200,6 +200,11 @@ func (h *Handler) listConversations(w http.ResponseWriter, r *http.Request) {
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)
if err != nil {
writeErr(w, r, err)
@@ -230,6 +235,7 @@ func (h *Handler) createConversation(w http.ResponseWriter, r *http.Request) {
ProjectID: req.ProjectID,
WorkspaceID: req.WorkspaceID,
IssueID: req.IssueID,
RequirementID: req.RequirementID,
})
if err != nil {
writeErr(w, r, err)
+5 -4
View File
@@ -1,6 +1,6 @@
-- name: InsertConversation :one
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
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, $9)
RETURNING *;
-- 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 ($3::uuid IS NULL OR workspace_id = $3)
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
LIMIT $6;
LIMIT $7;
-- name: UpdateConversationTitle :exec
UPDATE conversations SET title = $2, title_generated_at = NOW(), updated_at = NOW()
+8 -2
View File
@@ -20,8 +20,8 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"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"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// Postgres epoch: 2000-01-01 00:00:00 UTC
@@ -109,6 +109,7 @@ type InsertConversationParams struct {
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
RequirementID *uuid.UUID
ModelID uuid.UUID
SystemPrompt string
Title *string
@@ -313,6 +314,7 @@ func convFromRow(r chatsqlc.Conversation) *Conversation {
ProjectID: fromPgUUIDPtr(r.ProjectID),
WorkspaceID: fromPgUUIDPtr(r.WorkspaceID),
IssueID: fromPgUUIDPtr(r.IssueID),
RequirementID: fromPgUUIDPtr(r.RequirementID),
ModelID: fromPgUUID(r.ModelID),
SystemPrompt: r.SystemPrompt,
Title: r.Title,
@@ -477,6 +479,7 @@ func (r *pgRepo) InsertConversation(ctx context.Context, p InsertConversationPar
ModelID: toPgUUID(p.ModelID),
SystemPrompt: p.SystemPrompt,
Title: p.Title,
RequirementID: toPgUUIDPtr(p.RequirementID),
})
if err != nil {
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 {
limit = 20
}
// Column2..Column6 与 SQL 位置参数一一对应:$2=project, $3=workspace,
// $4=issue, $5=requirement, $6=title(sqlc 未命名参数按序生成字段)。
rows, err := r.q.ListConversationsByUser(ctx, chatsqlc.ListConversationsByUserParams{
UserID: toPgUUID(userID),
Column2: toPgUUIDPtr(f.ProjectID),
Column3: toPgUUIDPtr(f.WorkspaceID),
Column4: toPgUUIDPtr(f.IssueID),
Column5: f.TitleQuery,
Column5: toPgUUIDPtr(f.RequirementID),
Column6: f.TitleQuery,
Limit: int32(limit),
})
if err != nil {
+1
View File
@@ -66,6 +66,7 @@ func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in C
ProjectID: in.ProjectID,
WorkspaceID: in.WorkspaceID,
IssueID: in.IssueID,
RequirementID: in.RequirementID,
ModelID: in.ModelID,
SystemPrompt: systemPrompt,
Title: nil,
+34 -2
View File
@@ -45,6 +45,7 @@ func (r *convFakeRepo) InsertConversation(_ context.Context, p InsertConversatio
ProjectID: p.ProjectID,
WorkspaceID: p.WorkspaceID,
IssueID: p.IssueID,
RequirementID: p.RequirementID,
ModelID: p.ModelID,
SystemPrompt: p.SystemPrompt,
Title: p.Title,
@@ -70,9 +71,13 @@ func (r *convFakeRepo) ListConversationsByUser(_ context.Context, userID uuid.UU
defer r.mu.Unlock()
var out []Conversation
for _, c := range r.conversations {
if c.UserID == userID && c.DeletedAt == nil {
out = append(out, *c)
if c.UserID != userID || c.DeletedAt != nil {
continue
}
if f.RequirementID != nil && (c.RequirementID == nil || *c.RequirementID != *f.RequirementID) {
continue
}
out = append(out, *c)
}
limit := f.Limit
if limit > 0 && len(out) > limit {
@@ -235,6 +240,33 @@ func TestConversationService_Create_HappyPath(t *testing.T) {
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) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
+16 -8
View File
@@ -12,7 +12,7 @@ import (
)
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) {
@@ -31,14 +31,15 @@ func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversa
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.RequirementID,
)
return i, err
}
const insertConversation = `-- name: InsertConversation :one
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at
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, $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, requirement_id
`
type InsertConversationParams struct {
@@ -50,6 +51,7 @@ type InsertConversationParams struct {
ModelID pgtype.UUID `json:"model_id"`
SystemPrompt string `json:"system_prompt"`
Title *string `json:"title"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
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.SystemPrompt,
arg.Title,
arg.RequirementID,
)
var i Conversation
err := row.Scan(
@@ -77,19 +80,21 @@ func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversation
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.RequirementID,
)
return i, err
}
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
AND ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR workspace_id = $3)
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
LIMIT $6
LIMIT $7
`
type ListConversationsByUserParams struct {
@@ -97,7 +102,8 @@ type ListConversationsByUserParams struct {
Column2 pgtype.UUID `json:"column_2"`
Column3 pgtype.UUID `json:"column_3"`
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"`
}
@@ -108,6 +114,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat
arg.Column3,
arg.Column4,
arg.Column5,
arg.Column6,
arg.Limit,
)
if err != nil {
@@ -130,6 +137,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.RequirementID,
); err != nil {
return nil, err
}
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
+1 -1
View File
@@ -14,7 +14,7 @@ type ServerDeps struct {
Projects project.ProjectService
Reqs project.RequirementService
Issues project.IssueService
Prototypes project.PrototypeService
Artifacts project.ArtifactService
Workspaces workspace.WorkspaceService
Templates chat.TemplateService
Messages chat.MessageService
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
+1 -1
View File
@@ -62,7 +62,7 @@ var defaultSystemTools = []string{
"get_issue", "get_requirement",
"create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue",
"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。
+44 -41
View File
@@ -29,8 +29,8 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
registerCloseRequirement(srv, deps)
registerReopenRequirement(srv, deps)
registerSetRequirementPhase(srv, deps)
registerAddRequirementPrototype(srv, deps)
registerListRequirementPrototypes(srv, deps)
registerAddRequirementArtifact(srv, deps)
registerListRequirementArtifacts(srv, deps)
}
// ===== 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"`
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"`
}
type prototypeDetail struct {
type artifactDetail struct {
Phase string `json:"phase"`
Version int `json:"version"`
Content string `json:"content"`
Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"`
}
type prototypeCreateOut struct {
type artifactCreateOut struct {
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.Tool{Name: "add_requirement_prototype", Description: "Add a new prototype version to a requirement."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addRequirementPrototypeIn) (*mcpsdk.CallToolResult, prototypeCreateOut, error) {
if err := CheckToolFromCtx(ctx, "add_requirement_prototype"); err != nil {
return nil, prototypeCreateOut{}, err
&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 addRequirementArtifactIn) (*mcpsdk.CallToolResult, artifactCreateOut, error) {
if err := CheckToolFromCtx(ctx, "add_requirement_artifact"); err != nil {
return nil, artifactCreateOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, prototypeCreateOut{}, err
return nil, artifactCreateOut{}, err
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, prototypeCreateOut{}, err
return nil, artifactCreateOut{}, err
}
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{
Content: in.Content, Note: in.Note,
art, err := deps.Artifacts.Create(ctx, c, in.ProjectSlug, in.Number, project.CreateArtifactInput{
Phase: project.Phase(in.Phase), Content: in.Content, Note: in.Note,
})
if err != nil {
return nil, prototypeCreateOut{}, err
return nil, artifactCreateOut{}, err
}
return nil, prototypeCreateOut{OK: true, Prototype: prototypeDetail{
Version: proto.Version, Content: proto.Content, Note: proto.Note,
CreatedAt: proto.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
return nil, artifactCreateOut{OK: true, Artifact: artifactDetail{
Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note,
CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}}, nil
})
}
// ===== list_requirement_prototypes =====
// ===== list_requirement_artifacts =====
type listRequirementPrototypesIn struct {
type listRequirementArtifactsIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
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 {
Prototypes []prototypeDetail `json:"prototypes"`
type listRequirementArtifactsOut struct {
Artifacts []artifactDetail `json:"artifacts"`
}
func registerListRequirementPrototypes(srv *mcpsdk.Server, deps ServerDeps) {
func registerListRequirementArtifacts(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "list_requirement_prototypes", Description: "List prototype versions of a requirement."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementPrototypesIn) (*mcpsdk.CallToolResult, listRequirementPrototypesOut, error) {
if err := CheckToolFromCtx(ctx, "list_requirement_prototypes"); err != nil {
return nil, listRequirementPrototypesOut{}, err
&mcpsdk.Tool{Name: "list_requirement_artifacts", Description: "List phase artifact versions of a requirement."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementArtifactsIn) (*mcpsdk.CallToolResult, listRequirementArtifactsOut, error) {
if err := CheckToolFromCtx(ctx, "list_requirement_artifacts"); err != nil {
return nil, listRequirementArtifactsOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, listRequirementPrototypesOut{}, err
return nil, listRequirementArtifactsOut{}, err
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, listRequirementPrototypesOut{}, err
return nil, listRequirementArtifactsOut{}, err
}
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 {
return nil, listRequirementPrototypesOut{}, err
return nil, listRequirementArtifactsOut{}, err
}
out := listRequirementPrototypesOut{Prototypes: make([]prototypeDetail, 0, len(protos))}
for _, proto := range protos {
out.Prototypes = append(out.Prototypes, prototypeDetail{
Version: proto.Version, Content: proto.Content, Note: proto.Note,
CreatedAt: proto.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
out := listRequirementArtifactsOut{Artifacts: make([]artifactDetail, 0, len(arts))}
for _, art := range arts {
out.Artifacts = append(out.Artifacts, artifactDetail{
Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note,
CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
return nil, out, nil
+121
View File
@@ -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,
})
}
+249
View File
@@ -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
View File
@@ -40,6 +40,20 @@ func (p Phase) IsValid() bool {
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 通用的开/关状态。
type Status string
@@ -115,14 +129,18 @@ type Issue struct {
UpdatedAt time.Time
}
// Prototype 是某条 Requirement 的一个原型设计版本快照。Version 在同一 Requirement
// 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
type Prototype struct {
// Artifact 是某条 Requirement 在某阶段的一个产物文档版本快照。Version 在
// (RequirementID, Phase) 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
// SourceMessageID 非 nil 时指向该产物派生自的 chat assistant 消息(仅追溯用,
// schema 层 FK ON DELETE SET NULL,代码层不依赖 chat 模块)。
type Artifact struct {
ID uuid.UUID
RequirementID uuid.UUID
Phase Phase
Version int
Content string
Note string
SourceMessageID *int64
CreatedBy uuid.UUID
CreatedAt time.Time
}
@@ -169,11 +187,12 @@ type IssueService interface {
Reopen(ctx context.Context, c Caller, projectSlug string, number int) error
}
// PrototypeService 暴露 Requirement 原型设计版本的应用服务方法。
type PrototypeService interface {
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error)
List(ctx context.Context, c Caller, projectSlug string, reqNumber int) ([]*Prototype, error)
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, version int) (*Prototype, error)
// ArtifactService 暴露 Requirement 阶段产物版本的应用服务方法。
type ArtifactService interface {
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreateArtifactInput) (*Artifact, error)
// List 的 phase 传零值("")表示返回全部阶段产物(done 阶段汇总视图用)。
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 =====
@@ -240,11 +259,14 @@ type CreateIssueInput struct {
WorkspaceID *uuid.UUID
}
// CreatePrototypeInput 创建一个原型设计版本。Version 由 Service 自动计算(max+1),
// 不在此处提供。Note 可为空字符串。
type CreatePrototypeInput struct {
// CreateArtifactInput 创建一个阶段产物版本。Version 由 Service 自动计算
// ((requirement, phase) 内 max+1),不在此处提供。Phase 必须是 ArtifactPhases
// 之一;Note 可为空;SourceMessageID 仅在产物派生自 chat 消息时提供。
type CreateArtifactInput struct {
Phase Phase
Content string
Note string
SourceMessageID *int64
}
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
+36 -28
View File
@@ -38,14 +38,14 @@ type Handler struct {
projects ProjectService
requirements RequirementService
issues IssueService
prototypes PrototypeService
artifacts ArtifactService
resolver middleware.SessionResolver
users AdminLookup
}
// NewHandler 构造 Handler。
func NewHandler(p ProjectService, r RequirementService, i IssueService, proto PrototypeService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{projects: p, requirements: r, issues: i, prototypes: proto, resolver: resolver, users: users}
func NewHandler(p ProjectService, r RequirementService, i IssueService, arts ArtifactService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{projects: p, requirements: r, issues: i, artifacts: arts, resolver: resolver, users: users}
}
// 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}/close", h.closeRequirement)
r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
r.Post("/{slug}/requirements/{number}/prototypes", h.createPrototype)
r.Get("/{slug}/requirements/{number}/prototypes", h.listPrototypes)
r.Get("/{slug}/requirements/{number}/prototypes/{version}", h.getPrototype)
r.Post("/{slug}/requirements/{number}/artifacts", h.createArtifact)
r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts)
r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact)
r.Post("/{slug}/issues", h.createIssue)
r.Get("/{slug}/issues", h.listIssues)
@@ -161,12 +161,14 @@ type issueDTO struct {
UpdatedAt string `json:"updated_at"`
}
type prototypeDTO struct {
type artifactDTO struct {
ID string `json:"id"`
RequirementID string `json:"requirement_id"`
Phase string `json:"phase"`
Version int `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
}
@@ -231,12 +233,13 @@ func toIssueDTO(i *Issue) issueDTO {
return d
}
func toPrototypeDTO(p *Prototype) prototypeDTO {
return prototypeDTO{
ID: p.ID.String(), RequirementID: p.RequirementID.String(),
Version: p.Version, Content: p.Content, Note: p.Note,
CreatedBy: p.CreatedBy.String(),
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
func toArtifactDTO(a *Artifact) artifactDTO {
return artifactDTO{
ID: a.ID.String(), RequirementID: a.RequirementID.String(),
Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note,
SourceMessageID: a.SourceMessageID,
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)
}
// ===== Prototype endpoints =====
// ===== Artifact endpoints =====
type createPrototypeReq struct {
type createArtifactReq struct {
Phase string `json:"phase"`
Content string `json:"content"`
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)
if err != nil {
writeErr(w, r, err)
@@ -602,22 +607,23 @@ func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err)
return
}
var req createPrototypeReq
var req createArtifactReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
out, err := h.prototypes.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreatePrototypeInput{
Content: req.Content, Note: req.Note,
out, err := h.artifacts.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreateArtifactInput{
Phase: Phase(req.Phase), Content: req.Content, Note: req.Note,
SourceMessageID: req.SourceMessageID,
})
if err != nil {
writeErr(w, r, err)
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)
if err != nil {
writeErr(w, r, err)
@@ -628,19 +634,20 @@ func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err)
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 {
writeErr(w, r, err)
return
}
dtos := make([]prototypeDTO, 0, len(out))
dtos := make([]artifactDTO, 0, len(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)
if err != nil {
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 非法"))
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 {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototype": toPrototypeDTO(out)})
httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
}
// ===== Issue endpoints =====
+53 -2
View File
@@ -34,9 +34,9 @@ func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fake
pSvc := NewProjectService(repo, nil)
rSvc := NewRequirementService(repo, nil)
iSvc := NewIssueService(repo, nil)
protoSvc := NewPrototypeService(repo, nil)
artSvc := NewArtifactService(repo, nil)
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.Use(middleware.RequestID)
h.Mount(r)
@@ -214,3 +214,54 @@ func TestHandler_Requirement_PATCH_WorkspaceID_TriState(t *testing.T) {
t.Cleanup(func() { _ = resp5.Body.Close() })
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)
}
+32 -27
View File
@@ -23,11 +23,11 @@ type fakeRepo struct {
projects map[uuid.UUID]*Project
requirements map[uuid.UUID]*Requirement
issues map[uuid.UUID]*Issue
prototypes map[uuid.UUID]*Prototype
artifacts map[uuid.UUID]*Artifact
// 置 true 时,下一次 CreatePrototype 返回一次真实的 PG 唯一约束冲突后自动复位,
// 用于覆盖 prototypeService.createWithRetry 的 version 竞争重试分支。
createPrototypeFailOnce bool
// 置 true 时,下一次 CreateArtifact 返回一次真实的 PG 唯一约束冲突后自动复位,
// 用于覆盖 artifactService.createWithRetry 的 version 竞争重试分支。
createArtifactFailOnce bool
}
func newFakeRepo() *fakeRepo {
@@ -35,14 +35,14 @@ func newFakeRepo() *fakeRepo {
projects: map[uuid.UUID]*Project{},
requirements: map[uuid.UUID]*Requirement{},
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) cloneReq(p *Requirement) *Requirement { 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) {
r.mu.Lock()
@@ -369,58 +369,63 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error {
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()
defer r.mu.Unlock()
maxVer := 0
for _, x := range r.prototypes {
if x.RequirementID == requirementID && x.Version > maxVer {
for _, x := range r.artifacts {
if x.RequirementID == requirementID && x.Phase == phase && x.Version > maxVer {
maxVer = x.Version
}
}
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()
defer r.mu.Unlock()
if r.createPrototypeFailOnce {
r.createPrototypeFailOnce = false
if r.createArtifactFailOnce {
r.createArtifactFailOnce = false
// 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
}
for _, x := range r.prototypes {
if x.RequirementID == in.RequirementID && x.Version == in.Version {
for _, x := range r.artifacts {
if x.RequirementID == in.RequirementID && x.Phase == in.Phase && x.Version == in.Version {
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
}
}
in.CreatedAt = time.Now()
r.prototypes[in.ID] = r.clonePrototype(in)
return r.clonePrototype(in), nil
r.artifacts[in.ID] = r.cloneArtifact(in)
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()
defer r.mu.Unlock()
out := make([]*Prototype, 0)
for _, x := range r.prototypes {
if x.RequirementID == requirementID {
out = append(out, r.clonePrototype(x))
out := make([]*Artifact, 0)
for _, x := range r.artifacts {
if x.RequirementID == requirementID && (phase == "" || x.Phase == phase) {
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
}
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()
defer r.mu.Unlock()
for _, x := range r.prototypes {
if x.RequirementID == requirementID && x.Version == version {
return r.clonePrototype(x), nil
for _, x := range r.artifacts {
if x.RequirementID == requirementID && x.Phase == phase && x.Version == version {
return r.cloneArtifact(x), nil
}
}
return nil, errs.New(errs.CodeNotFound, "prototype 不存在")
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
}
// spyAudit 收集所有写入的 audit entry,方便断言。
-110
View File
@@ -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,
})
}
-181
View File
@@ -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)
}
+19
View File
@@ -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;
-19
View File
@@ -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;
+37 -25
View File
@@ -51,11 +51,12 @@ type Repository interface {
CloseIssue(ctx context.Context, id uuid.UUID) error
ReopenIssue(ctx context.Context, id uuid.UUID) error
// Prototype
CreatePrototype(ctx context.Context, p *Prototype) (*Prototype, error)
ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error)
GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error)
GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, error)
// Artifact
CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error)
// ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。
ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, 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*
@@ -498,68 +499,79 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error {
return nil
}
// ===== Prototype =====
// ===== Artifact =====
func rowToPrototype(row projectsqlc.RequirementPrototype) *Prototype {
return &Prototype{
func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact {
return &Artifact{
ID: fromPgUUID(row.ID),
RequirementID: fromPgUUID(row.RequirementID),
Phase: Phase(row.Phase),
Version: int(row.Version),
Content: row.Content,
Note: row.Note,
SourceMessageID: row.SourceMessageID,
CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time,
}
}
func (r *pgRepo) CreatePrototype(ctx context.Context, in *Prototype) (*Prototype, error) {
row, err := r.q.CreateRequirementPrototype(ctx, projectsqlc.CreateRequirementPrototypeParams{
func (r *pgRepo) CreateArtifact(ctx context.Context, in *Artifact) (*Artifact, error) {
row, err := r.q.CreateRequirementArtifact(ctx, projectsqlc.CreateRequirementArtifactParams{
ID: toPgUUID(in.ID),
RequirementID: toPgUUID(in.RequirementID),
Phase: string(in.Phase),
Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出
Content: in.Content,
Note: in.Note,
SourceMessageID: in.SourceMessageID,
CreatedBy: toPgUUID(in.CreatedBy),
})
if err != nil {
if IsUniqueViolation(err) {
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) {
rows, err := r.q.ListRequirementPrototypesByRequirement(ctx, toPgUUID(requirementID))
func (r *pgRepo) ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) {
rows, err := r.q.ListRequirementArtifactsByRequirement(ctx, projectsqlc.ListRequirementArtifactsByRequirementParams{
RequirementID: toPgUUID(requirementID),
Column2: string(phase),
})
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 {
out = append(out, rowToPrototype(row))
out = append(out, rowToArtifact(row))
}
return out, nil
}
func (r *pgRepo) GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error) {
row, err := r.q.GetRequirementPrototypeByVersion(ctx, projectsqlc.GetRequirementPrototypeByVersionParams{
func (r *pgRepo) GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) {
row, err := r.q.GetRequirementArtifactByVersion(ctx, projectsqlc.GetRequirementArtifactByVersionParams{
RequirementID: toPgUUID(requirementID),
Phase: string(phase),
Version: int32(version), //nolint:gosec // version 来自 service 层 int,原值即来自 DB int32
})
if err != nil {
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) {
max, err := r.q.GetMaxRequirementPrototypeVersion(ctx, toPgUUID(requirementID))
func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
max, err := r.q.GetMaxRequirementArtifactVersion(ctx, projectsqlc.GetMaxRequirementArtifactVersionParams{
RequirementID: toPgUUID(requirementID),
Phase: string(phase),
})
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
}
+143
View File
@@ -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
}
+4 -1
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,12 +277,14 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type RequirementPrototype struct {
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"`
}
-122
View File
@@ -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
}
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
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 {
ID pgtype.UUID `json:"id"`
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
View File
@@ -127,8 +127,13 @@ export const acpApi = {
request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }),
},
sessions: {
list: (all?: boolean) =>
request<AcpSession[]>('/api/v1/acp/sessions' + (all ? '?all=true' : '')),
list: (opts: { all?: boolean; requirement_id?: string } = {}) => {
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) =>
request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
create: (req: CreateSessionReq) =>
+3
View File
@@ -10,6 +10,7 @@ export interface Conversation {
project_id?: string | null
workspace_id?: string | null
issue_id?: string | null
requirement_id?: string | null
model_id: string
system_prompt: string
title?: string | null
@@ -72,6 +73,7 @@ export interface ListConversationsParams {
project_id?: string
workspace_id?: string
issue_id?: string
requirement_id?: string
q?: string
limit?: number
}
@@ -83,6 +85,7 @@ export interface CreateConversationBody {
project_id?: string
workspace_id?: string
issue_id?: string
requirement_id?: string
}
export interface UsageDailyItem {
+19 -10
View File
@@ -47,12 +47,17 @@ export interface Issue {
updated_at: string
}
export interface Prototype {
// ArtifactPhase 是允许产出文档型产物的三个阶段(与后端 ArtifactPhases 一致)。
export type ArtifactPhase = 'planning' | 'prototyping' | 'auditing'
export interface Artifact {
id: string
requirement_id: string
phase: ArtifactPhase
version: number
content: string
note: string
source_message_id?: number | null
created_by: string
created_at: string
}
@@ -126,19 +131,23 @@ export const projectsApi = {
reopenRequirement: (slug: string, number: number) =>
request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }),
// ===== Prototype =====
listPrototypes: (slug: string, number: number) =>
request<{ prototypes: Prototype[] }>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`,
// ===== Artifact =====
listArtifacts: (slug: string, number: number, phase?: ArtifactPhase) =>
request<Artifact[]>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts${phase ? `?phase=${phase}` : ''}`,
),
createPrototype: (slug: string, number: number, body: { content: string; note?: string }) =>
request<Prototype>(`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`, {
createArtifact: (
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',
body,
}),
getPrototype: (slug: string, number: number, version: number) =>
request<{ prototype: Prototype }>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes/${version}`,
getArtifact: (slug: string, number: number, phase: ArtifactPhase, version: number) =>
request<Artifact>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts/${phase}/${version}`,
),
// ===== 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')
}
+2 -2
View File
@@ -60,11 +60,11 @@ export const useAcpStore = defineStore('acp', () => {
return acpApi.agentKinds.get(id)
}
async function listSessions(all = false) {
async function listSessions(all = false, requirementId?: string) {
loading.value = true
error.value = null
try {
sessions.value = await acpApi.sessions.list(all)
sessions.value = await acpApi.sessions.list({ all, requirement_id: requirementId })
} catch (e) {
error.value = e instanceof Error ? e.message : 'failed'
} finally {
+19 -217
View File
@@ -1,239 +1,41 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import {
chatApi,
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']
}
import { chatApi, type Conversation, type ListConversationsParams } from '@/api/chat'
import { useConversationSession } from '@/composables/useConversationSession'
// chat store = 全局 /chat 页那一份会话实例 + 会话列表。
// "单会话消息流"逻辑在 useConversationSession 中(可多实例,详情页内嵌面板自建),
// 这里持有一份并原样转发,保持对外 API 不变(ChatDetailView 零改动)。
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>([])
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)
const session = useConversationSession()
async function loadConversations(filter: ListConversationsParams = {}) {
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) {
await chatApi.deleteConversation(id)
conversations.value = conversations.value.filter((c) => c.id !== id)
if (currentConversation.value?.id === id) {
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
}
if (session.currentConversation.value?.id === id) {
session.closeCurrent()
}
}
return {
conversations,
currentConversation,
messages,
streamingMessageId,
isStreaming,
currentConversation: session.currentConversation,
messages: session.messages,
streamingMessageId: session.streamingMessageId,
isStreaming: session.isStreaming,
loadConversations,
openConversation,
loadOlderMessages,
closeCurrent,
sendMessage,
cancelMessage,
retryMessage,
openConversation: session.openConversation,
loadOlderMessages: session.loadOlderMessages,
closeCurrent: session.closeCurrent,
sendMessage: session.sendMessage,
cancelMessage: session.cancelMessage,
retryMessage: session.retryMessage,
deleteConversation,
}
})
+21 -17
View File
@@ -2,8 +2,9 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
projectsApi,
type Artifact,
type ArtifactPhase,
type Phase,
type Prototype,
type Requirement,
type Status,
} from '@/api/projects'
@@ -51,26 +52,29 @@ export const useRequirementsStore = defineStore('requirements', () => {
await refresh(forSlug)
}
async function listPrototypes(forSlug: string, number: number): Promise<Prototype[]> {
const out = await projectsApi.listPrototypes(forSlug, number)
return out.prototypes
}
async function createPrototype(
async function listArtifacts(
forSlug: string,
number: number,
body: { content: string; note?: string },
): Promise<Prototype> {
return await projectsApi.createPrototype(forSlug, number, body)
phase?: ArtifactPhase,
): Promise<Artifact[]> {
return await projectsApi.listArtifacts(forSlug, number, phase)
}
async function getPrototype(
async function createArtifact(
forSlug: string,
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,
): Promise<Prototype> {
const out = await projectsApi.getPrototype(forSlug, number, version)
return out.prototype
): Promise<Artifact> {
return await projectsApi.getArtifact(forSlug, number, phase, version)
}
return {
@@ -84,8 +88,8 @@ export const useRequirementsStore = defineStore('requirements', () => {
changePhase,
close,
reopen,
listPrototypes,
createPrototype,
getPrototype,
listArtifacts,
createArtifact,
getArtifact,
}
})
+16 -1
View File
@@ -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', () => ({
useRoute: () => ({ params: { slug: 'p', wsSlug: 'w' } }),
useRoute: () => routeMock,
useRouter: () => ({ push: vi.fn() }),
}))
@@ -55,6 +60,7 @@ import AcpSessionCreateView from './AcpSessionCreateView.vue'
beforeEach(() => {
setActivePinia(createPinia())
routeMock.query = {}
})
describe('AcpSessionCreateView', () => {
@@ -63,4 +69,13 @@ describe('AcpSessionCreateView', () => {
await new Promise((r) => setTimeout(r, 20))
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)
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)
if (wsStore.list.length === 0) {
await wsStore.fetchByProject(route.params.slug as string)
@@ -62,6 +62,18 @@
in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }}
<span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span>
</div>
<div
v-if="canSave"
class="mt-2"
>
<NButton
size="tiny"
secondary
@click="emit('save', message.id)"
>
保存为产物
</NButton>
</div>
</div>
</div>
</template>
@@ -74,7 +86,15 @@ import MarkdownIt from 'markdown-it'
import type { ChatMessage } from '@/api/chat'
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const props = defineProps<{ message: ChatMessage }>()
const emit = defineEmits<{ retry: [id: number] }>()
// savable true assistant
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 canSave = computed(
() =>
props.savable === true &&
props.message.role === 'assistant' &&
props.message.status === 'ok' &&
props.message.content.trim() !== '',
)
</script>
+73 -138
View File
@@ -47,7 +47,7 @@
</div>
</div>
<!-- Phase flow bar -->
<!-- Phase flow bar阶段跳转始终由用户确认触发 -->
<div class="flex items-center gap-1">
<template
v-for="(phase, idx) in PHASES"
@@ -69,6 +69,9 @@
</template>
</div>
<!-- 左右布局=需求信息+当前阶段产物=按阶段切换的 AI 工作面板 -->
<div class="flex gap-6 items-start">
<div class="w-[45%] shrink-0 space-y-6">
<!-- Description -->
<NCard title="描述">
<p
@@ -85,98 +88,15 @@
</p>
</NCard>
<!-- Prototypes -->
<NCard title="原型">
<div class="flex gap-4">
<!-- Version list -->
<div class="w-56 shrink-0">
<NList
v-if="prototypes.length > 0"
:show-divider="false"
>
<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"
<!-- 当前阶段产物 AI 对话三阶段显示 -->
<ArtifactPanel
v-if="chatPhase"
ref="artifactPanelRef"
:slug="slug"
:number="number"
:phase="chatPhase"
:readonly="data.requirement.status !== 'open'"
/>
</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 -->
<NCard title="工作区">
@@ -222,6 +142,34 @@
</NCard>
</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
v-else-if="!loading"
class="text-gray-400 text-sm py-8 text-center"
@@ -233,7 +181,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDialog } from 'naive-ui'
import {
@@ -246,26 +194,31 @@ import {
NListItem,
NThing,
NEmpty,
NInput,
NDivider,
} from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { projectsApi } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements'
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 PHASE_LABELS: Record<Phase, string> = {
planning: '规划中',
prototyping: '原型设计',
auditing: '审中',
auditing: '审中',
implementing: '实施中',
reviewing: '评审中',
reviewing: '验收中',
done: '已完成',
}
const CHAT_PHASES: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
const route = useRoute()
const router = useRouter()
const dialog = useDialog()
@@ -278,20 +231,18 @@ const number = Number(route.params.number)
const loading = ref(false)
const toggling = ref(false)
const data = ref<RequirementDetail | null>(null)
const prototypes = ref<Prototype[]>([])
const selectedVersion = ref<number | null>(null)
const newContent = ref('')
const newNote = ref('')
const submitting = ref(false)
const artifactPanelRef = ref<InstanceType<typeof ArtifactPanel> | null>(null)
const prevArtifact = ref<Artifact | null>(null)
const workspaceOptions = computed(() =>
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
)
const selectedPrototype = computed(() =>
prototypes.value.find((p) => p.version === selectedVersion.value) ?? null,
)
// AI 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() {
loading.value = true
@@ -301,44 +252,25 @@ async function fetchData() {
wsStore.fetchByProject(slug),
])
data.value = detail
await refreshPrototypes()
} finally {
loading.value = false
}
}
async function refreshPrototypes() {
prototypes.value = await store.listPrototypes(slug, number)
//
if (prototypes.value.length > 0) {
const latest = prototypes.value[prototypes.value.length - 1]
if (selectedVersion.value === null || !selectedPrototype.value) {
selectedVersion.value = latest.version
}
} else {
selectedVersion.value = null
}
// system promptplanning
async function loadPrevArtifact() {
prevArtifact.value = null
if (!chatPhase.value) return
const prevPhase = PREV_ARTIFACT_PHASE[chatPhase.value]
if (!prevPhase) return
const list = await store.listArtifacts(slug, number, prevPhase)
prevArtifact.value = list.length > 0 ? list[list.length - 1] : null
}
function onSelectPrototype(version: number) {
selectedVersion.value = version
}
watch(chatPhase, loadPrevArtifact)
async function onSubmitPrototype() {
if (!newContent.value.trim()) return
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
}
function onArtifactSaved() {
artifactPanelRef.value?.refresh()
}
async function onWorkspaceChange(v: string | null) {
@@ -384,5 +316,8 @@ async function onReopen() {
}
}
onMounted(fetchData)
onMounted(async () => {
await fetchData()
await loadPrevArtifact()
})
</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>