feat(documents): rebuild project document workbench

This commit is contained in:
2026-07-24 12:16:02 +08:00
parent 366136cee9
commit 77beaebb30
73 changed files with 5867 additions and 1264 deletions

View File

@@ -50,7 +50,7 @@ type WorkspaceDTO struct {
Inbox []WorkspaceInboxDTO `json:"inbox"`
Tasks []WorkspaceTaskDTO `json:"tasks"`
AISessions []WorkspaceAISessionDTO `json:"aiSessions"`
NotesSources []WorkspaceNoteSourceDTO `json:"notesSources"`
Documents []WorkspaceDocumentDTO `json:"documents"`
CronPlans []WorkspaceCronPlanDTO `json:"cronPlans"`
}
@@ -121,15 +121,15 @@ type WorkspaceAISessionDTO struct {
References []string `json:"references"`
}
// WorkspaceNoteSourceDTO 将笔记和资料合并为统一的工作区列表项。
type WorkspaceNoteSourceDTO struct {
// WorkspaceDocumentDTO is the recent document summary used by the workspace.
type WorkspaceDocumentDTO struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Kind string `json:"kind"`
Title string `json:"title"`
Name string `json:"name"`
Extension string `json:"extension"`
MimeType string `json:"mimeType"`
UpdatedAt time.Time `json:"updatedAt"`
Tag string `json:"tag"`
Source string `json:"source"`
}
// WorkspaceCronPlanDTO 是只展示元数据、不执行自主 Agent 的计划任务摘要。

View File

@@ -127,8 +127,8 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: 1, Title: "Confirm IA", Description: "Review channel layout.", Status: "open", DueAt: &nextRun}).Error)
require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: 1, Title: "Archive notes", Description: "Move outdated notes.", Status: "done"}).Error)
require.NoError(t, database.Create(&models.SaAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI prototype critique", Context: "Discuss channel behavior."}).Error)
require.NoError(t, database.Create(&models.SaNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes"}).Error)
require.NoError(t, database.Create(&models.SaSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com"}).Error)
require.NoError(t, database.Create(&models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Workbench principles.md", NormalizedName: "workbench principles.md", Extension: ".md", Revision: 1}).Error)
require.NoError(t, database.Create(&models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Reference board.pdf", NormalizedName: "reference board.pdf", Extension: ".pdf", Revision: 1}).Error)
require.NoError(t, database.Create(&models.SaProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap-a1", SortOrder: 7}).Error)
require.NoError(t, database.Create(&models.SaProjectChannel{ProjectID: other.ID, Title: "Other Board", Icon: "link", URL: "https://example.com/other", SortOrder: 7}).Error)
require.NoError(t, database.Create(&models.SaCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly inbox review reminder", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, LastResult: "Not run yet"}).Error)
@@ -158,9 +158,9 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
require.NotEmpty(t, workspace.Tasks[0].CreatedAt)
require.Len(t, workspace.AISessions, 1)
require.Len(t, workspace.RecentSessions, 1)
require.Len(t, workspace.NotesSources, 2)
require.Equal(t, "note", workspace.NotesSources[0].Kind)
require.Equal(t, "link", workspace.NotesSources[1].Kind)
require.Len(t, workspace.Documents, 2)
require.Equal(t, models.DocumentKindFile, workspace.Documents[0].Kind)
require.Equal(t, models.DocumentKindFile, workspace.Documents[1].Kind)
require.Len(t, workspace.CronPlans, 1)
require.True(t, workspace.CronPlans[0].Enabled)
require.Equal(t, "David", workspace.CronPlans[0].Owner)

View File

@@ -13,7 +13,7 @@ type workspaceCounts struct {
inbox int64
tasks int64
aiSessions int64
notesSources int64
documents int64
cronPlans int64
}
@@ -48,7 +48,7 @@ func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO,
if err != nil {
return WorkspaceDTO{}, err
}
notesSources, err := s.workspaceNotesSources(project.ID, project.Identity)
documents, err := s.workspaceDocuments(project.ID, project.Identity)
if err != nil {
return WorkspaceDTO{}, err
}
@@ -74,7 +74,7 @@ func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO,
Inbox: inboxItems,
Tasks: tasks,
AISessions: aiSessions,
NotesSources: notesSources,
Documents: documents,
CronPlans: cronPlans,
}, nil
}
@@ -90,15 +90,9 @@ func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) {
if err := models.DBService.Model(&models.SaAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil {
return counts, err
}
var noteCount int64
if err := models.DBService.Model(&models.SaNote{}).Where("project_id = ?", projectID).Count(&noteCount).Error; err != nil {
if err := models.DBService.Model(&models.SaDocument{}).Where("project_id = ?", projectID).Count(&counts.documents).Error; err != nil {
return counts, err
}
var sourceCount int64
if err := models.DBService.Model(&models.SaSource{}).Where("project_id = ?", projectID).Count(&sourceCount).Error; err != nil {
return counts, err
}
counts.notesSources = noteCount + sourceCount
if err := models.DBService.Model(&models.SaCronPlan{}).Where("project_id = ?", projectID).Count(&counts.cronPlans).Error; err != nil {
return counts, err
}
@@ -118,13 +112,13 @@ func (s *Service) workspaceTags(projectID uint) ([]WorkspaceTagDTO, error) {
}
func (s *Service) workspaceChannels(projectID uint, projectIdentity string, counts workspaceCounts) ([]WorkspaceChannelDTO, error) {
total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans
total := counts.inbox + counts.tasks + counts.aiSessions + counts.documents + counts.cronPlans
channels := []WorkspaceChannelDTO{
{ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: total, URL: "", SortOrder: 1},
{ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: counts.inbox, URL: "", SortOrder: 2},
{ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: counts.tasks, URL: "", SortOrder: 3},
{ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: counts.aiSessions, URL: "", SortOrder: 4},
{ID: projectIdentity + ":notes", ProjectID: projectIdentity, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: counts.notesSources, URL: "", SortOrder: 5},
{ID: projectIdentity + ":documents", ProjectID: projectIdentity, Type: "documents", Title: "Documents", Icon: "file", Count: counts.documents, URL: "", SortOrder: 5},
{ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: counts.cronPlans, URL: "", SortOrder: 6},
}
var customChannels []models.SaProjectChannel
@@ -229,27 +223,16 @@ func (s *Service) workspaceAISessions(projectID uint, projectIdentity string) ([
return items, nil
}
func (s *Service) workspaceNotesSources(projectID uint, projectIdentity string) ([]WorkspaceNoteSourceDTO, error) {
var notes []models.SaNote
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&notes).Error; err != nil {
func (s *Service) workspaceDocuments(projectID uint, projectIdentity string) ([]WorkspaceDocumentDTO, error) {
var documents []models.SaDocument
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&documents).Error; err != nil {
return nil, err
}
items := make([]WorkspaceNoteSourceDTO, 0, len(notes))
for _, note := range notes {
items = append(items, WorkspaceNoteSourceDTO{
ID: note.Identity, ProjectID: projectIdentity, Kind: "note", Title: note.Title,
UpdatedAt: note.UpdatedAt.UTC(), Source: "Markdown",
})
}
var sources []models.SaSource
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sources).Error; err != nil {
return nil, err
}
for _, source := range sources {
items = append(items, WorkspaceNoteSourceDTO{
ID: source.Identity, ProjectID: projectIdentity, Kind: source.Kind, Title: source.Title,
UpdatedAt: source.UpdatedAt.UTC(), Source: sourceDisplayName(source),
items := make([]WorkspaceDocumentDTO, 0, len(documents))
for _, document := range documents {
items = append(items, WorkspaceDocumentDTO{
ID: document.Identity, ProjectID: projectIdentity, Kind: document.Kind, Name: document.Name,
Extension: document.Extension, MimeType: document.MimeType, UpdatedAt: document.UpdatedAt.UTC(),
})
}
return items, nil
@@ -283,17 +266,6 @@ func utcOptionalTime(value *time.Time) *time.Time {
return &utc
}
func sourceDisplayName(source models.SaSource) string {
switch source.Kind {
case "file":
return "Attachment"
case "link":
return "URL"
default:
return source.Kind
}
}
func userDisplayName(assigneeID *uint, createdBy uint) (string, error) {
userID := createdBy
if assigneeID != nil && *assigneeID != 0 {

View File

@@ -33,11 +33,11 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) {
inbox := models.SaInboxItem{ProjectID: project.ID, CreatedBy: 1, SourceType: "Manual", Title: "Collect notes", Body: "Turn research into tasks.", Status: "open", CreatedAt: createdAt, UpdatedAt: updatedAt}
task := models.SaTask{ProjectID: project.ID, CreatedBy: 1, TagID: &projectTag.ID, Title: "Confirm IA", Description: "Review channel layout.", Status: "done", DueAt: &nextRun, CreatedAt: createdAt, UpdatedAt: updatedAt}
session := models.SaAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI critique", Context: "Discuss channel behavior.", CreatedAt: createdAt, UpdatedAt: updatedAt}
note := models.SaNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes", CreatedAt: createdAt, UpdatedAt: updatedAt}
source := models.SaSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com", CreatedAt: createdAt, UpdatedAt: updatedAt}
firstDocument := models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Workbench principles.md", NormalizedName: "workbench principles.md", Extension: ".md", MimeType: "text/markdown", Revision: 1, CreatedAt: createdAt, UpdatedAt: updatedAt}
secondDocument := models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Reference board.pdf", NormalizedName: "reference board.pdf", Extension: ".pdf", MimeType: "application/pdf", Revision: 1, CreatedAt: createdAt, UpdatedAt: updatedAt}
channel := models.SaProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap", SortOrder: 7, CreatedAt: createdAt, UpdatedAt: updatedAt}
plan := models.SaCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly review", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, CreatedAt: createdAt, UpdatedAt: updatedAt}
for _, record := range []any{&inbox, &task, &session, &note, &source, &channel, &plan} {
for _, record := range []any{&inbox, &task, &session, &firstDocument, &secondDocument, &channel, &plan} {
require.NoError(t, database.Create(record).Error)
}
@@ -71,10 +71,10 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) {
require.Equal(t, updatedAt.UTC(), workspace.AISessions[0].UpdatedAt)
require.Equal(t, session.Identity, workspace.RecentSessions[0].ID)
require.Len(t, workspace.NotesSources, 2)
require.Equal(t, note.Identity, workspace.NotesSources[0].ID)
require.Equal(t, source.Identity, workspace.NotesSources[1].ID)
for _, item := range workspace.NotesSources {
require.Len(t, workspace.Documents, 2)
require.Equal(t, secondDocument.Identity, workspace.Documents[0].ID)
require.Equal(t, firstDocument.Identity, workspace.Documents[1].ID)
for _, item := range workspace.Documents {
require.Equal(t, project.Identity, item.ProjectID)
require.Equal(t, updatedAt.UTC(), item.UpdatedAt)
}