From eea28f0be437cb55530f158b9a7de34e9a4c999f Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Fri, 11 Sep 2026 13:22:02 +0800 Subject: [PATCH] feat(task): inject uploaded documents into task materials and enable LAN host access - backend: add Materials field to TaskRunRequestDTO and forward to ExecuteTask - backend: add TestTaskStream_MaterialsInjection test case in taskstream_test.go - web: serialize materials in buildTaskRequest and add unit test - web: improve isSameMessage identity comparison in messageIdentity.ts - web: configure host: '0.0.0.0' in vite.config.ts for LAN collaboration access --- internal/server/handlers.go | 47 +++++++++++---------- internal/server/taskstream_test.go | 67 ++++++++++++++++++++++++++++++ web/src/api/taskStream.test.ts | 15 +++++++ web/src/api/taskStream.ts | 3 ++ web/src/engine/messageIdentity.ts | 6 +++ web/vite.config.ts | 1 + 6 files changed, 117 insertions(+), 22 deletions(-) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 7f245097..8bcbea49 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -43,28 +43,30 @@ type ChatRequest struct { // TaskRunRequestDTO 独立任务运行请求 type TaskRunRequestDTO struct { - TaskKey string `json:"task_key"` - Stage string `json:"stage,omitempty"` // 若指定 stage,则执行成熟度阶段跃迁 - ProjectName string `json:"project_name"` - CustomerName string `json:"customer_name"` - DeviceType string `json:"device_type"` - IntendedUse string `json:"intended_use"` - TargetMarket string `json:"target_market"` - Fields map[string]string `json:"fields,omitempty"` - Backend string `json:"backend,omitempty"` - AutoRepair bool `json:"auto_repair"` - UserID int64 `json:"user_id,omitempty"` - ProjectID int64 `json:"project_id,omitempty"` - DocID string `json:"doc_id,omitempty"` - AuthorID int64 `json:"author_id,omitempty"` - AuthorName string `json:"author_name,omitempty"` - ReviewerID int64 `json:"reviewer_id,omitempty"` - ReviewerName string `json:"reviewer_name,omitempty"` - ApproverID int64 `json:"approver_id,omitempty"` - ApproverName string `json:"approver_name,omitempty"` - ForceDirectEdit bool `json:"force_direct_edit,omitempty"` - CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"` - WorkflowKey string `json:"workflow_key,omitempty"` + TaskKey string `json:"task_key"` + Stage string `json:"stage,omitempty"` // 若指定 stage,则执行成熟度阶段跃迁 + ProjectName string `json:"project_name"` + CustomerName string `json:"customer_name"` + DeviceType string `json:"device_type"` + IntendedUse string `json:"intended_use"` + TargetMarket string `json:"target_market"` + Fields map[string]string `json:"fields,omitempty"` + Backend string `json:"backend,omitempty"` + AutoRepair bool `json:"auto_repair"` + UserID int64 `json:"user_id,omitempty"` + ProjectID int64 `json:"project_id,omitempty"` + DocID string `json:"doc_id,omitempty"` + AuthorID int64 `json:"author_id,omitempty"` + AuthorName string `json:"author_name,omitempty"` + ReviewerID int64 `json:"reviewer_id,omitempty"` + ReviewerName string `json:"reviewer_name,omitempty"` + ApproverID int64 `json:"approver_id,omitempty"` + ApproverName string `json:"approver_name,omitempty"` + ForceDirectEdit bool `json:"force_direct_edit,omitempty"` + CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"` + WorkflowKey string `json:"workflow_key,omitempty"` + // Materials 用户上传的参考文档材料,由前端 Base64 编码后传入;后端 json 反序列化自动解码 []byte + Materials []bizdev.MaterialInput `json:"materials,omitempty"` } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -223,6 +225,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { IntendedUse: req.IntendedUse, TargetMarket: req.TargetMarket, Fields: req.Fields, + Materials: req.Materials, Backend: req.Backend, AutoRepair: req.AutoRepair, UserID: req.UserID, diff --git a/internal/server/taskstream_test.go b/internal/server/taskstream_test.go index 90d72734..ca01e253 100644 --- a/internal/server/taskstream_test.go +++ b/internal/server/taskstream_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" @@ -217,3 +218,69 @@ func TestTaskStream_ResumeInvalidWorkspace_ClassifiedResumable(t *testing.T) { t.Fatalf("actions=%v want [continue restart]", errEv.ErrorInfo.Actions) } } + +// TestTaskStream_MaterialsInjection 验证前端传入的 materials(Base64 编码的附件内容) +// 能被后端 HTTP handler 正确反序列化,透传给 ExecuteTask,并写入工作区的 materials_text/ 目录 +func TestTaskStream_MaterialsInjection(t *testing.T) { + cfg := &config.Config{ + Env: "test", + AgentBackend: "stub", + DataDir: t.TempDir(), + KbDbPort: 5432, + } + svc := bizdev.NewService(cfg) + srv := NewServer(cfg, svc) + + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + + // 模拟前端发来的包含 materials 字段的请求 + // Content 经过 Base64 编码:"用户上传文档核心需求:需要支持 1080P 超声实时渲染。" + const rawText = "用户上传文档核心需求:需要支持 1080P 超声实时渲染。" + b64Content := "55So5oi35LiK5Lyg5paH5qGj5qC45b+D6ZyA5rGC77ya6ZyA6KaB5pSv5oyBIDEwODBQIOi2heWjsOWunuaXtua4suafk+OAgg==" + + body, _ := json.Marshal(map[string]any{ + "task_key": "intake", + "project_name": "超声材料注入测试", + "customer_name": "北京水木东方", + "device_type": "超声诊断设备", + "backend": "stub", + "materials": []map[string]string{ + { + "filename": "initial_requirements.txt", + "content": b64Content, + }, + }, + }) + + resp, err := http.Post(ts.URL+"/api/v1/tasks/run", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post tasks run failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var result struct { + WorkDir string `json:"work_dir"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode response failed: %v", err) + } + if result.WorkDir == "" { + t.Fatal("expected non-empty work_dir in response") + } + + // 验证 materials_text 目录下是否存在 initial_requirements.txt 且内容还原正确 + savedFile := result.WorkDir + "/materials_text/initial_requirements.txt" + content, err := os.ReadFile(savedFile) + if err != nil { + t.Fatalf("expected material file at %s: %v", savedFile, err) + } + if string(content) != rawText { + t.Fatalf("material content mismatch: got %q, want %q", string(content), rawText) + } +} + diff --git a/web/src/api/taskStream.test.ts b/web/src/api/taskStream.test.ts index 8a992309..d2b4424c 100644 --- a/web/src/api/taskStream.test.ts +++ b/web/src/api/taskStream.test.ts @@ -35,6 +35,21 @@ test('buildTaskRequest: omits optional fields when absent', () => { assert.equal('target_market' in req, false); assert.equal('user_id' in req, false); assert.equal('project_id' in req, false); + assert.equal('materials' in req, false); +}); + +test('buildTaskRequest: includes materials when provided and non-empty', () => { + const req = buildTaskRequest({ + taskKey: 'intake', + projectName: 'P', + customerName: 'C', + deviceType: 'D', + materials: [ + { filename: 'doc1.txt', content: 'b64content==' }, + ], + }); + assert.equal('materials' in req, true); + assert.deepEqual(req.materials, [{ filename: 'doc1.txt', content: 'b64content==' }]); }); test('buildResumeRequest: adds work_dir on top of task request', () => { diff --git a/web/src/api/taskStream.ts b/web/src/api/taskStream.ts index 5b456860..5f366659 100644 --- a/web/src/api/taskStream.ts +++ b/web/src/api/taskStream.ts @@ -58,6 +58,9 @@ export function buildTaskRequest(task: TaskRunContext): Record if (task.targetMarket) body.target_market = task.targetMarket; if (task.userId) body.user_id = task.userId; if (task.projectId) body.project_id = task.projectId; + if (task.materials && task.materials.length > 0) { + body.materials = task.materials; + } return body; } diff --git a/web/src/engine/messageIdentity.ts b/web/src/engine/messageIdentity.ts index 82df6ea3..24c19f3b 100644 --- a/web/src/engine/messageIdentity.ts +++ b/web/src/engine/messageIdentity.ts @@ -21,6 +21,9 @@ export function isSameMessage(a: ChatMessage, b: ChatMessage): boolean { if (a.sessionId !== b.sessionId || a.role !== b.role) { return false; } + if (a.id && b.id && a.id === b.id) { + return true; + } if (a.clientTurnId && b.clientTurnId && a.clientTurnId === b.clientTurnId) { return true; } @@ -30,6 +33,9 @@ export function isSameMessage(a: ChatMessage, b: ChatMessage): boolean { if (a.clientMessageId && b.clientMessageId && a.clientMessageId === b.clientMessageId) { return true; } + if (a.content === b.content && a.timestamp === b.timestamp) { + return true; + } return false; } diff --git a/web/vite.config.ts b/web/vite.config.ts index 9ff9f615..7137afb0 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ include: ['src/components/chat/**/*.test.ts'], }, server: { + host: '0.0.0.0', port: 3000, proxy: { '/api': {