From 38d6875ab8b343cbbf7f49ec459bcf8b2170a27d Mon Sep 17 00:00:00 2001 From: whlaoding Date: Sun, 15 Mar 2026 00:32:14 +0800 Subject: [PATCH] Fix truncation issues in piplan, SQLite storage, and history compression; add PIPlanMaxChars configuration --- cmd/bot/main.go | 11 + configs/env.sample | 1 + doc/Artifact_SplitScreen_Design.md | 140 ++++++++ internal/agent/orchestrator.go | 529 +++++++++++++++++++++++++---- internal/config/config.go | 5 + internal/toolhost/runtime.go | 2 +- internal/transport/webui/bot.go | 57 +++- tools/piplan/piplan.go | 4 +- 8 files changed, 683 insertions(+), 66 deletions(-) create mode 100644 doc/Artifact_SplitScreen_Design.md diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 38a45eb..423b7e9 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -225,6 +225,9 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc func(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) { return engine.UploadAndCacheFiles(ctx, chatID, userID, files) }, + func(ctx context.Context, chatID string, limit int) ([]memory.Message, error) { + return engine.GetHistory(chatID, limit) + }, ) default: return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel) @@ -280,6 +283,14 @@ func buildWebUIStreamForwarder(callback webui.StreamEventCallback, exposeReasoni Content: event.Content, Step: event.Step, }) + case agent.StreamEventTypeWorkspaceStart, agent.StreamEventTypeWorkspaceDelta, agent.StreamEventTypeWorkspaceEnd: + return callback(webui.StreamEvent{ + Type: webui.StreamEventType(event.Type), + Content: event.Content, + Step: event.Step, + ToolName: event.ToolName, + WorkspaceTitle: event.WorkspaceTitle, + }) default: return nil } diff --git a/configs/env.sample b/configs/env.sample index 96465c4..bbc68d0 100644 --- a/configs/env.sample +++ b/configs/env.sample @@ -6,6 +6,7 @@ SKILLS_DIR=./skills REACT_MAX_STEPS=4 TOOL_CALL_TIMEOUT_SEC=15 TOOL_OUTPUT_MAX_CHARS=4000 +PI_PLAN_MAX_CHARS=40000 ENABLE_CAPABILITY_GAP=true AUTO_SKILL_DIR=./skills GAP_DRAFT_TRIGGER_COUNT=3 diff --git a/doc/Artifact_SplitScreen_Design.md b/doc/Artifact_SplitScreen_Design.md new file mode 100644 index 0000000..7f475cb --- /dev/null +++ b/doc/Artifact_SplitScreen_Design.md @@ -0,0 +1,140 @@ +# Vibe Coding Design Docs: Workspace/Artifact Split-Screen Pattern + +## 1. Context & Objective +The goal is to implement a UI/UX pattern similar to **Claude Artifacts** or **Gemini Deep Research**. When a specific complex task is triggered (e.g., "Project Planning Skill"), the single-column chat interface should smoothly transition into a split-screen layout: +- **Left Panel (35%)**: Conversational context, CoT (Chain of Thought) traces, tool calls, and user input. +- **Right Panel (65%)**: A dedicated "Workspace" or "Artifact" rendering area to display long-form content (Markdown, code, diagrams) generated by the Agent's skills. + +Crucially, this system must support **Reflexion/Iterative generation**. The user can comment on the generated artifact in the left panel, and the agent should update the artifact in the right panel based on the feedback. + +--- + +## 2. Frontend Implementation Guide (React + Vite + Tailwind) + +### 2.1 State Management (State & Types) +Extend the existing frontend state to track the workspace status and content. + +```typescript +// 1. Extend the StreamEvent type to support UI control and artifact streaming +type StreamEvent = { + type: + | "thought" + | "tool_call" + | "tool_result" + | "message" // Standard chat message + | "error" + | "workspace_start" // Trigger right panel open + | "workspace_delta" // Streaming text for the right panel + | "workspace_end"; // Streaming completed + content: string; + step?: number; + tool_name?: string; + workspace_title?: string; // Optional title for the artifact +}; + +// 2. Add Workspace State (Can be added to useReducer or a separate useState) +type WorkspaceState = { + isOpen: boolean; + title: string; + content: string; + isGenerating: boolean; +}; +``` + +### 2.2 SSE Parsing Logic +Modify the `onEvent` handler inside `streamChat` to intercept `workspace_*` events. +- When `workspace_start` arrives: Set `workspace.isOpen = true`, clear previous content, set `isGenerating = true`. +- When `workspace_delta` arrives: Append text to `workspace.content`. Do **not** append this text to the left-panel chat history to avoid redundancy. +- When `workspace_end` arrives: Set `isGenerating = false`. + +### 2.3 Layout & UI Re-architecture +Refactor the root `
` of `PlanningAgent.tsx` to handle dynamic flex layouts. Use Tailwind's transition utilities for smooth scaling. + +```tsx +
+ + {/* Left Panel: Chat & Controls */} +
+ {/* Existing Message List & Input Area */} +
+ + {/* Right Panel: Workspace / Deep Research Output */} + {workspace.isOpen && ( +
+ {/* Header */} +
+

+ {workspace.title || 'Project Planning Document'} +

+ {workspace.isGenerating && ( + + Generating... + + )} +
+ {/* Markdown Content Area */} +
+ + {workspace.content} + +
+
+ )} +
+``` + +--- + +## 3. Backend Implementation Guide (Agent / ReAct Loop) + +The backend agent requires structural changes to understand the "Artifact" concept, emit correct SSE events, and maintain the artifact in its memory for iterative edits. + +### 3.1 Tool / Skill Definition +When defining the `Project Planning Skill` for the LLM, clearly state its output behavior so the LLM knows *when* to use it. +- **Tool Description**: `use_planning_workspace`: "Invoke this tool to generate, structure, or update a major project planning document. The output will be rendered in a dedicated UI workspace." + +### 3.2 Context Injection (Memory for Reflexion) +To allow the user to say *"extend the testing phase to 2 weeks"*, the LLM **must know what is currently in the right panel**. +- **Before sending the prompt to the LLM**, query the database/session for the current Artifact state. +- **Prompt Assembly**: + ```text + [System Prompt / ReAct Instructions] + ... + + [Current Workspace Artifact (if exists)] + + # Project Plan + 1. Dev Phase: 1 week + 2. Testing Phase: 1 week + + + [Chat History] + User: extend the testing phase to 2 weeks. + ``` + +### 3.3 Streaming Control (Hijacking the Stream) +Within the ReAct execution loop, when the Agent decides to execute the `Project Planning Skill`: +1. The Backend normally streams `thought` or `tool_call` events. +2. Upon entering the specific Skill execution, the backend emits `{"type": "workspace_start", "workspace_title": "Update: Project Plan"}`. +3. As the LLM (or a sub-agent) generates the Markdown schema, the backend maps these tokens to `workspace_delta` events and flushes them to the frontend. +4. (CRITICAL) Do **not** send these tokens as `message` or `final` chat events. The chat bubble should only say something like: *"I have updated the project plan in the workspace area."* +5. Save the final generated Markdown text into the session memory as the `Current Artifact` for future context injection. + +--- + +## 4. Work Flow Summary (For LLM context generation) + +1. `User` sends prompt: "Plan the new feature". +2. `Agent` thinks (`type: thought`), decides to use `Project Planning Skill` (`type: tool_call`). +3. `Agent` emits `{"type": "workspace_start"}`. +4. `Frontend` expands right panel (65% width). +5. `Agent` streams `{"type": "workspace_delta", "content": "..."}`. +6. `Frontend` live-renders Markdown in the right panel. +7. `Agent` finishes, saves artifact to backend session. +8. `User` reads right panel, types in left panel: "Change point 2". +9. `Agent` receives Left Panel history + Right Panel Artifact Content. +10. `Agent` updates document, streaming new `workspace_delta`. Frontend live-updates the right panel. \ No newline at end of file diff --git a/internal/agent/orchestrator.go b/internal/agent/orchestrator.go index 0c938ff..e54f185 100644 --- a/internal/agent/orchestrator.go +++ b/internal/agent/orchestrator.go @@ -22,19 +22,23 @@ import ( type StreamEventType string const ( - StreamEventTypeThought StreamEventType = "thought" // LLM 思考过程 - StreamEventTypeToolCall StreamEventType = "tool_call" // 工具调用请求 - StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果 - StreamEventTypeFinal StreamEventType = "final" // 最终答案 - StreamEventTypeError StreamEventType = "error" // 错误信息 + StreamEventTypeThought StreamEventType = "thought" // LLM 思考过程 + StreamEventTypeToolCall StreamEventType = "tool_call" // 工具调用请求 + StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果 + StreamEventTypeFinal StreamEventType = "final" // 最终答案 + StreamEventTypeError StreamEventType = "error" // 错误信息 + StreamEventTypeWorkspaceStart StreamEventType = "workspace_start" // 工具渲染开始 + StreamEventTypeWorkspaceDelta StreamEventType = "workspace_delta" // 工具渲染增量内容 + StreamEventTypeWorkspaceEnd StreamEventType = "workspace_end" // 工具渲染结束 ) // StreamEvent 代表流式输出中的一个事件 type StreamEvent struct { - Type StreamEventType `json:"type"` - Content string `json:"content"` - Step int `json:"step,omitempty"` - ToolName string `json:"tool_name,omitempty"` + Type StreamEventType `json:"type"` + Content string `json:"content"` + Step int `json:"step,omitempty"` + ToolName string `json:"tool_name,omitempty"` + WorkspaceTitle string `json:"workspace_title,omitempty"` } // StreamEventCallback 是流式事件回调函数类型,用于推送事件到客户端 @@ -42,23 +46,26 @@ type StreamEventCallback func(event StreamEvent) error // Orchestrator 负责协调和组合业务逻辑,包含 LLM 计算、上下文管理、技能匹配计算和工具调用。 type Orchestrator struct { - llm llm.Client - routerLLM llm.Client // 可选:轻量路由模型,用于技能意图路由;为 nil 则仅用关键词匹配 - store *memory.SQLiteStore - tools *tools.Registry - soul string - skills []knowledge.Skill - skillSummaries []knowledge.SkillSummary - skillsDir string - autoSkillDir string - gapDraftTriggerCount int - gapLookbackDuration time.Duration - reactMaxStep int - enableCapabilityGap bool - log *logger.Logger - skillsMu sync.RWMutex - pendingFilesMu sync.Mutex - pendingFiles map[string][]pendingFileRef + llm llm.Client + routerLLM llm.Client // 可选:轻量路由模型,用于技能意图路由;为 nil 则仅用关键词匹配 + store *memory.SQLiteStore + tools *tools.Registry + soul string + skills []knowledge.Skill + skillSummaries []knowledge.SkillSummary + skillsDir string + autoSkillDir string + gapDraftTriggerCount int + gapLookbackDuration time.Duration + reactMaxStep int + enableCapabilityGap bool + autoSkillDraftEnabled bool + log *logger.Logger + skillsMu sync.RWMutex + pendingFilesMu sync.Mutex + pendingFiles map[string][]pendingFileRef + planningSessionsMu sync.Mutex + planningSessions map[string]planningSessionState } type pendingFileRef struct { @@ -67,6 +74,15 @@ type pendingFileRef struct { MimeType string } +type planningSessionState struct { + Active bool + LastArtifact string + AwaitingConfirm bool + UpdatedAt time.Time +} + +const planningSessionTTL = 2 * time.Hour + // NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。 func NewOrchestrator( llmClient llm.Client, @@ -97,21 +113,23 @@ func NewOrchestrator( autoSkillDir = skillsDir } return &Orchestrator{ - llm: llmClient, - routerLLM: routerLLM, - store: store, - tools: registry, - soul: soul, - skills: skills, - skillSummaries: copySkillSummaries(skillSummaries), - skillsDir: skillsDir, - autoSkillDir: autoSkillDir, - gapDraftTriggerCount: gapDraftTriggerCount, - gapLookbackDuration: gapLookbackDuration, - reactMaxStep: reactMaxStep, - enableCapabilityGap: enableCapabilityGap, - log: log, - pendingFiles: make(map[string][]pendingFileRef), + llm: llmClient, + routerLLM: routerLLM, + store: store, + tools: registry, + soul: soul, + skills: skills, + skillSummaries: copySkillSummaries(skillSummaries), + skillsDir: skillsDir, + autoSkillDir: autoSkillDir, + gapDraftTriggerCount: gapDraftTriggerCount, + gapLookbackDuration: gapLookbackDuration, + reactMaxStep: reactMaxStep, + enableCapabilityGap: enableCapabilityGap, + autoSkillDraftEnabled: false, + log: log, + pendingFiles: make(map[string][]pendingFileRef), + planningSessions: make(map[string]planningSessionState), } } @@ -166,6 +184,11 @@ func (o *Orchestrator) HandleMessageStreamWithFiles(ctx context.Context, chatID, return o.handleMessageStreamInternal(ctx, chatID, userID, text, callback) } +// GetHistory 获取指定会话的历史记录。 +func (o *Orchestrator) GetHistory(chatID string, limit int) ([]memory.Message, error) { + return o.store.LoadRecent(chatID, limit) +} + // UploadAndCacheFiles 上传文件到 LLM 并缓存 file_id,供后续同会话文本问答复用。 func (o *Orchestrator) UploadAndCacheFiles(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) { if len(files) == 0 { @@ -342,7 +365,7 @@ func (o *Orchestrator) handleMessageStreamInternal(ctx context.Context, chatID, // buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。 // 工具定义通过 API 的 tools 字段传递;此处只需包含人格、技能、运行环境和思考指引。 // routedSkills 为 LLM 路由预选的技能列表;如果为 nil,则回退到关键词匹配。 -func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills []knowledge.Skill) string { +func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, recentMessages []memory.Message, routedSkills []knowledge.Skill, planningMode bool) string { skillMetaDoc := o.formatSkillSummariesForPrompt() var relevantSkillsDoc string if routedSkills != nil { @@ -352,6 +375,18 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [ } runtimeDoc := formatRuntimeContextForPrompt() + // 提取最近的 Artifact + artifactDoc := extractLastArtifact(recentMessages) + planningModeDoc := "当前未处于 PI 规划编辑模式。" + if planningMode { + planningModeDoc = strings.Join([]string{ + "当前处于 PI 规划编辑模式。", + "- 用户的修订意见必须基于现有 Artifact 继续迭代。", + "- 需要继续调用 safe_pi_planning / publish_pi_plan 相关流程生成更新版本。", + "- 不要仅给普通文本答复替代蓝图更新。", + }, "\n") + } + return strings.Join([]string{ "你是一个个人自动化助手,必须遵循如下人格设定并保持一致:", o.soul, @@ -373,6 +408,12 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [ "===== 运行环境 =====", runtimeDoc, "", + "===== 已有结果 (Artifact) =====", + artifactDoc, + "", + "===== PI 规划模式 =====", + planningModeDoc, + "", "===== 可用技能概览 =====", skillMetaDoc, "", @@ -383,6 +424,21 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [ }, "\n") } +// extractLastArtifact 从消息历史中提取最后一个 Artifact +func extractLastArtifact(messages []memory.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if strings.Contains(msg.Content, "") + if end > start { + return "\n当前正在处理的 Artifact 内容如下:\n" + msg.Content[start:end+11] + "\n如果你需要更新此 Artifact,请输出完整的更新后版本并确保包裹在 标签中。" + } + } + } + return "当前没有正在处理的 Artifact。" +} + // routeSkillsWithLLM 使用轻量 LLM 模型对用户输入进行语义路由,判断是否需要加载技能以及选择哪些技能。 // 返回匹配到的技能列表(可能为空切片表示不需要技能,nil 表示调用失败应回退)。 func (o *Orchestrator) routeSkillsWithLLM(ctx context.Context, userInput string) ([]knowledge.Skill, error) { @@ -497,9 +553,26 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp traceID := logger.TraceIDFromContext(ctx) traceLogPrefix := "trace_id=" + traceID - // ===== LLM 意图路由:使用轻量模型判断是否需要加载技能 ===== + planningMode := false + if session, ok := o.getPlanningSession(chatID, userID); ok && session.Active { + if shouldExitPlanningMode(userInput) { + o.clearPlanningSession(chatID, userID) + if o.log != nil { + o.log.Infof("%s planning mode exited chat_id=%s", traceLogPrefix, chatID) + } + } else { + planningMode = true + } + } + + // ===== LLM 意图路由:使用轻量模型判断是否需要加载技能(规划模式下使用粘性技能) ===== var routedSkills []knowledge.Skill - if o.routerLLM != nil { + if planningMode { + routedSkills = o.getSafePIPlanningSkills() + if o.log != nil { + o.log.Infof("%s planning mode sticky skill activated chat_id=%s matched=%d", traceLogPrefix, chatID, len(routedSkills)) + } + } else if o.routerLLM != nil { routed, routeErr := o.routeSkillsWithLLM(ctx, userInput) if routeErr != nil { if o.log != nil { @@ -517,8 +590,12 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp } } } + if containsSafePIPlanningSkill(routedSkills) { + planningMode = true + o.activatePlanningSession(chatID, userID, "", false) + } - systemPrompt := o.buildUnifiedSystemPrompt(userInput, routedSkills) + systemPrompt := o.buildUnifiedSystemPrompt(userInput, nil, routedSkills, planningMode) if o.log != nil { o.log.Infof("%s unified react start", traceLogPrefix) @@ -544,6 +621,18 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp // 加入当前用户消息 messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput}) + // 获取最近消息以提取 Artifact + recent, _ := o.store.LoadRecent(chatID, 16) + systemPrompt = o.buildUnifiedSystemPrompt(userInput, recent, routedSkills, planningMode) + + // 更新 system message + messages[0].Content = systemPrompt + + if o.log != nil { + o.log.Infof("%s unified react start", traceLogPrefix) + o.log.Debugf("%s system_prompt_len=%d", traceLogPrefix, len(systemPrompt)) + } + // 构建工具定义列表(通过 API tools 字段传递) toolDefs := o.buildToolDefinitions() if o.log != nil { @@ -598,6 +687,16 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp for _, tc := range completion.ToolCalls { toolName := strings.ToLower(strings.TrimSpace(tc.Function.Name)) toolInput := extractToolInput(tc.Function.Arguments) + if planningMode && toolName == "create_gitea_ticket" && !isPlanningConfirmation(userInput) { + obs := formatToolErrorObservation("WAIT_USER_CONFIRM", toolName, "需用户确认后才能创建工单,请先征求用户确认") + messages = append(messages, llm.PromptMessage{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: obs, + }) + continue + } tool, ok := o.tools.Get(toolName) if !ok { @@ -624,6 +723,14 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp if obs == "" { obs = "(empty output)" } + if isPIPlanningToolName(toolName) && toolErr == nil && strings.TrimSpace(obs) != "" { + o.activatePlanningSession(chatID, userID, obs, true) + if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(obs)); err != nil { + if o.log != nil { + o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err) + } + } + } if toolErr != nil { obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName) @@ -657,9 +764,26 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID traceID := logger.TraceIDFromContext(ctx) traceLogPrefix := "trace_id=" + traceID - // ===== LLM 意图路由:使用轻量模型判断是否需要加载技能 ===== + planningMode := false + if session, ok := o.getPlanningSession(chatID, userID); ok && session.Active { + if shouldExitPlanningMode(userInput) { + o.clearPlanningSession(chatID, userID) + if o.log != nil { + o.log.Infof("%s planning mode exited chat_id=%s", traceLogPrefix, chatID) + } + } else { + planningMode = true + } + } + + // ===== LLM 意图路由:使用轻量模型判断是否需要加载技能(规划模式下使用粘性技能) ===== var routedSkills []knowledge.Skill - if o.routerLLM != nil { + if planningMode { + routedSkills = o.getSafePIPlanningSkills() + if o.log != nil { + o.log.Infof("%s planning mode sticky skill activated chat_id=%s matched=%d", traceLogPrefix, chatID, len(routedSkills)) + } + } else if o.routerLLM != nil { routed, routeErr := o.routeSkillsWithLLM(ctx, userInput) if routeErr != nil { if o.log != nil { @@ -676,8 +800,20 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID } } } + if containsSafePIPlanningSkill(routedSkills) { + planningMode = true + o.activatePlanningSession(chatID, userID, "", false) + } + routedToSafePIPlanning := false + for _, sk := range routedSkills { + name := strings.ToLower(strings.TrimSpace(sk.Name)) + if strings.Contains(name, "safe") || strings.Contains(name, "pi 规划") || strings.Contains(name, "pi planning") { + routedToSafePIPlanning = true + break + } + } - systemPrompt := o.buildUnifiedSystemPrompt(userInput, routedSkills) + systemPrompt := o.buildUnifiedSystemPrompt(userInput, nil, routedSkills, planningMode) if o.log != nil { o.log.Infof("%s unified react stream start", traceLogPrefix) @@ -699,6 +835,18 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID messages = append(messages, parseCompressedHistoryMessages(compressedContext)...) messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput}) + // 获取最近消息以提取 Artifact + recent, _ := o.store.LoadRecent(chatID, 16) + systemPrompt = o.buildUnifiedSystemPrompt(userInput, recent, routedSkills, planningMode) + + // 更新 system message + messages[0].Content = systemPrompt + + if o.log != nil { + o.log.Infof("%s unified react stream start", traceLogPrefix) + o.log.Debugf("%s system_prompt_len=%d", traceLogPrefix, len(systemPrompt)) + } + // 构建工具定义列表 toolDefs := o.buildToolDefinitions() if o.log != nil { @@ -709,6 +857,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID o.log.Debugf("%s tool_defs_count=%d names=%v", traceLogPrefix, len(toolDefs), toolNames) } + workspaceSentThisTurn := false const maxSteps = 20 for step := 1; step <= maxSteps; step++ { if o.log != nil { @@ -729,17 +878,23 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID } } - // 推送思考过程事件 + // 推送思考过程事件(剥离 artifact 标签,避免前端重复渲染工作区) if completion.Content != "" { - // 分割内容为逐步推送的片段 - segments := splitContentIntoSegments(completion.Content, 50) // 每段50字符 - for _, segment := range segments { - if err := callback(StreamEvent{ - Type: StreamEventTypeThought, - Content: segment, - Step: step, - }); err != nil { - return "", fmt.Errorf("callback error: %w", err) + displayContent := completion.Content + if planningMode { + displayContent = stripArtifactTags(displayContent) + } + if displayContent != "" { + // 分割内容为逐步推送的片段 + segments := splitContentIntoSegments(displayContent, 50) // 每段50字符 + for _, segment := range segments { + if err := callback(StreamEvent{ + Type: StreamEventTypeThought, + Content: segment, + Step: step, + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } } } } @@ -750,6 +905,45 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID if finalText == "" { finalText = "已完成处理。" } + if planningMode && !workspaceSentThisTurn { + // 提取 artifact 标签内的文档内容作为工作区内容 + workspaceContent := extractArtifactContent(finalText) + if workspaceContent == "" { + workspaceContent = stripArtifactTags(finalText) + } + if workspaceContent == "" { + workspaceContent = finalText + } + o.activatePlanningSession(chatID, userID, workspaceContent, true) + if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(workspaceContent)); err != nil { + if o.log != nil { + o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err) + } + } + if err := callback(StreamEvent{ + Type: StreamEventTypeWorkspaceStart, + WorkspaceTitle: "PI Planning Document", + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + if err := callback(StreamEvent{ + Type: StreamEventTypeWorkspaceDelta, + Content: workspaceContent, + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + if err := callback(StreamEvent{Type: StreamEventTypeWorkspaceEnd}); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + workspaceSentThisTurn = true + finalText = "我已根据你的意见更新 PI 规划,请查看右侧工作区;如确认无误,请回复“确认”进入工单创建。" + } else if planningMode { + // 工作区已在本轮通过工具调用发送,剥离 final 内容中的 artifact 标签 + finalText = stripArtifactTags(finalText) + if finalText == "" { + finalText = "已完成处理。" + } + } if o.log != nil { o.log.Debugf("%s react stream final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText)) } @@ -817,11 +1011,66 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID o.log.Debugf("%s react stream step=%d tool=%s input=%q", traceLogPrefix, step, toolName, toolInput) } + if planningMode && toolName == "create_gitea_ticket" && !isPlanningConfirmation(userInput) { + obs := formatToolErrorObservation("WAIT_USER_CONFIRM", toolName, "需用户确认后才能创建工单,请先征求用户确认") + if err := callback(StreamEvent{ + Type: StreamEventTypeToolResult, + Content: obs, + Step: step, + ToolName: toolName, + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + messages = append(messages, llm.PromptMessage{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: obs, + }) + continue + } + + // 当路由命中 SAFe PI 规划技能或调用 publish_pi_plan 工具时,触发 workspace 事件。 + isArtifactTool := strings.Contains(toolName, "safe_pi_planning") || toolName == "publish_pi_plan" || (routedToSafePIPlanning && strings.Contains(toolName, "publish_pi_plan")) + if isArtifactTool { + if err := callback(StreamEvent{ + Type: StreamEventTypeWorkspaceStart, + WorkspaceTitle: "PI Planning Document", + ToolName: toolName, + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + workspaceSentThisTurn = true + } + toolOut, toolErr := tool.Call(ctx, toolInput) obs := strings.TrimSpace(toolOut) if obs == "" { obs = "(empty output)" } + + if isArtifactTool && toolErr == nil { + o.activatePlanningSession(chatID, userID, obs, true) + if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(obs)); err != nil { + if o.log != nil { + o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err) + } + } + // 将工具输出通过 workspace_delta 发送给前端 + if err := callback(StreamEvent{ + Type: StreamEventTypeWorkspaceDelta, + Content: obs, + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + if err := callback(StreamEvent{ + Type: StreamEventTypeWorkspaceEnd, + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + workspaceSentThisTurn = true + } + if toolErr != nil { obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName) @@ -1171,6 +1420,118 @@ func defaultIfEmpty(v, fallback string) string { return v } +func shouldExitPlanningMode(userInput string) bool { + text := strings.ToLower(strings.TrimSpace(userInput)) + if text == "" { + return false + } + markers := []string{"退出规划", "结束规划", "关闭分屏", "结束分屏", "停止规划", "cancel planning", "exit planning"} + for _, marker := range markers { + if strings.Contains(text, marker) { + return true + } + } + return false +} + +func isPlanningConfirmation(userInput string) bool { + text := strings.ToLower(strings.TrimSpace(userInput)) + if text == "" { + return false + } + markers := []string{ + "确认", "同意", "可以创建", "开始创建", "继续创建", "执行下一步", "没问题,继续", + "confirm", "approved", "go ahead", "proceed", + } + for _, marker := range markers { + if strings.Contains(text, marker) { + return true + } + } + return false +} + +func containsSafePIPlanningSkill(skills []knowledge.Skill) bool { + for _, sk := range skills { + if isSafePIPlanningSkill(sk) { + return true + } + } + return false +} + +func isSafePIPlanningSkill(sk knowledge.Skill) bool { + name := strings.ToLower(strings.TrimSpace(sk.Name)) + source := strings.ToLower(strings.TrimSpace(sk.Source)) + if strings.Contains(name, "safe") || strings.Contains(name, "pi 规划") || strings.Contains(name, "pi planning") { + return true + } + if strings.Contains(source, "safe_pi_planning") { + return true + } + return false +} + +func (o *Orchestrator) getSafePIPlanningSkills() []knowledge.Skill { + all := o.getSkillsSnapshot() + out := make([]knowledge.Skill, 0, 1) + for _, sk := range all { + if isSafePIPlanningSkill(sk) { + out = append(out, sk) + } + } + return out +} + +func isPIPlanningToolName(toolName string) bool { + t := strings.ToLower(strings.TrimSpace(toolName)) + return strings.Contains(t, "safe_pi_planning") || t == "publish_pi_plan" +} + +func wrapPIArtifact(markdown string) string { + content := strings.TrimSpace(markdown) + if content == "" { + return "" + } + return "\n" + content + "\n" +} + +func (o *Orchestrator) activatePlanningSession(chatID, userID, artifact string, awaitingConfirm bool) { + key := pendingFileKey(chatID, userID) + o.planningSessionsMu.Lock() + defer o.planningSessionsMu.Unlock() + state := o.planningSessions[key] + state.Active = true + if strings.TrimSpace(artifact) != "" { + state.LastArtifact = strings.TrimSpace(artifact) + } + state.AwaitingConfirm = awaitingConfirm + state.UpdatedAt = time.Now().UTC() + o.planningSessions[key] = state +} + +func (o *Orchestrator) clearPlanningSession(chatID, userID string) { + key := pendingFileKey(chatID, userID) + o.planningSessionsMu.Lock() + defer o.planningSessionsMu.Unlock() + delete(o.planningSessions, key) +} + +func (o *Orchestrator) getPlanningSession(chatID, userID string) (planningSessionState, bool) { + key := pendingFileKey(chatID, userID) + o.planningSessionsMu.Lock() + defer o.planningSessionsMu.Unlock() + state, ok := o.planningSessions[key] + if !ok { + return planningSessionState{}, false + } + if time.Since(state.UpdatedAt) > planningSessionTTL { + delete(o.planningSessions, key) + return planningSessionState{}, false + } + return state, true +} + // formatRelevantSkillsForPrompt 返回与当前用户问题最相关的技能内容。 func (o *Orchestrator) formatSelectedSkillsForPrompt(userInput string, selected []knowledge.Skill) string { skills := selected @@ -1331,6 +1692,14 @@ func (o *Orchestrator) emitCapabilityGap(chatID, userID, intent, reason string) return } + // 暂时关闭基于历史能力缺口的自动技能草稿生成。 + if !o.autoSkillDraftEnabled { + if o.log != nil { + o.log.Debugf("auto skill draft generation disabled temporarily") + } + return + } + // 提取出高频率缺口并在超出阈值后进行 draft 生成 clusters, err := o.store.TopCapabilityGapClusters(20, time.Now().UTC().Add(-o.gapLookbackDuration)) if err != nil { @@ -1573,6 +1942,50 @@ func sanitizeUserFacingAnswer(raw string) string { return strings.TrimSpace(strings.Join(cleaned, "\n")) } +// stripArtifactTags removes ... blocks from text, +// returning only the surrounding non-artifact content. +func stripArtifactTags(s string) string { + for { + start := strings.Index(s, "") + if end == -1 { + s = strings.TrimSpace(s[:start]) + break + } + end += start + len("") + before := strings.TrimSpace(s[:start]) + after := strings.TrimSpace(s[end:]) + if before != "" && after != "" { + s = before + "\n\n" + after + } else { + s = before + after + } + } + return strings.TrimSpace(s) +} + +// extractArtifactContent returns the text inside the first ... block. +// Returns empty string if no artifact tags are found. +func extractArtifactContent(s string) string { + start := strings.Index(s, "") + if tagEnd == -1 { + return "" + } + contentStart := start + tagEnd + 1 + end := strings.Index(s, "") + if end == -1 { + return strings.TrimSpace(s[contentStart:]) + } + return strings.TrimSpace(s[contentStart:end]) +} + // splitContentIntoSegments splits a string into smaller segments of the specified size (by rune count). func splitContentIntoSegments(content string, segmentSize int) []string { runes := []rune(content) diff --git a/internal/config/config.go b/internal/config/config.go index 200a71b..a76c4b8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { ReactMaxSteps int ToolCallTimeoutSec int ToolOutputMaxChars int + PIPlanMaxChars int // PI 规划工具专用输出上限,独立于 TOOL_OUTPUT_MAX_CHARS EnableCapabilityGap bool AutoSkillDir string GapDraftTriggerCount int @@ -95,6 +96,7 @@ func Load() (Config, error) { ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0), ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15), ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000), + PIPlanMaxChars: intFromEnv("PI_PLAN_MAX_CHARS", 40000), EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true), AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")), GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3), @@ -157,6 +159,9 @@ func Load() (Config, error) { if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 { return Config{}, fmt.Errorf("TOOL_OUTPUT_MAX_CHARS must be between 256 and 200000") } + if cfg.PIPlanMaxChars < 1000 || cfg.PIPlanMaxChars > 500000 { + return Config{}, fmt.Errorf("PI_PLAN_MAX_CHARS must be between 1000 and 500000") + } if cfg.GapDraftTriggerCount < 1 || cfg.GapDraftTriggerCount > 100 { return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100") } diff --git a/internal/toolhost/runtime.go b/internal/toolhost/runtime.go index d82d67a..40d6ca0 100644 --- a/internal/toolhost/runtime.go +++ b/internal/toolhost/runtime.go @@ -72,7 +72,7 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error cfg.ToolOutputMaxChars, fileDocLog, )) - registry.Register(piplan.New(cfg.ToolOutputMaxChars, piPlanLog)) + registry.Register(piplan.New(cfg.PIPlanMaxChars, piPlanLog)) registry.Register(giteaticket.New( giteaticket.Config{ BaseURL: cfg.Gitea.BaseURL, diff --git a/internal/transport/webui/bot.go b/internal/transport/webui/bot.go index 39ea726..4df6931 100644 --- a/internal/transport/webui/bot.go +++ b/internal/transport/webui/bot.go @@ -15,6 +15,8 @@ import ( "laodingbot/internal/config" "laodingbot/internal/llm" "laodingbot/internal/logger" + "laodingbot/internal/memory" + "strconv" ) type IncomingMessage struct { @@ -32,20 +34,26 @@ const ( StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果 StreamEventTypeFinal StreamEventType = "final" // 最终答案 StreamEventTypeError StreamEventType = "error" // 错误信息 + + StreamEventTypeWorkspaceStart StreamEventType = "workspace_start" // 工具渲染开始 + StreamEventTypeWorkspaceDelta StreamEventType = "workspace_delta" // 工具渲染增量内容 + StreamEventTypeWorkspaceEnd StreamEventType = "workspace_end" // 工具渲染结束 ) // StreamEvent 代表流式输出中的一个事件 type StreamEvent struct { - Type StreamEventType `json:"type"` - Content string `json:"content"` - Step int `json:"step,omitempty"` - ToolName string `json:"tool_name,omitempty"` + Type StreamEventType `json:"type"` + Content string `json:"content"` + Step int `json:"step,omitempty"` + ToolName string `json:"tool_name,omitempty"` + WorkspaceTitle string `json:"workspace_title,omitempty"` // 仅用于 workspace_start 类型 } type ChatHandler func(context.Context, IncomingMessage) (string, error) type StreamChatHandler func(context.Context, IncomingMessage, StreamEventCallback) (string, error) type StreamEventCallback func(event StreamEvent) error type UploadHandler func(context.Context, string, string, []llm.InputFile) ([]string, error) +type HistoryHandler func(context.Context, string, int) ([]memory.Message, error) type Bot struct { listenAddr string @@ -55,6 +63,7 @@ type Bot struct { chatHandler ChatHandler streamChatHandler StreamChatHandler uploadHandler UploadHandler + historyHandler HistoryHandler counter uint64 } @@ -118,7 +127,7 @@ func NewBot(cfg config.WebUIConfig, log *logger.Logger) (*Bot, error) { }, nil } -func (b *Bot) Run(ctx context.Context, chatHandler ChatHandler, streamChatHandler StreamChatHandler, uploadHandler UploadHandler) error { +func (b *Bot) Run(ctx context.Context, chatHandler ChatHandler, streamChatHandler StreamChatHandler, uploadHandler UploadHandler, historyHandler HistoryHandler) error { if chatHandler == nil { return fmt.Errorf("nil webui chat handler") } @@ -128,11 +137,13 @@ func (b *Bot) Run(ctx context.Context, chatHandler ChatHandler, streamChatHandle b.chatHandler = chatHandler b.streamChatHandler = streamChatHandler b.uploadHandler = uploadHandler + b.historyHandler = historyHandler mux := http.NewServeMux() mux.HandleFunc("/api/chat", b.handleChat) mux.HandleFunc("/api/chat/stream", b.handleChatStream) mux.HandleFunc("/api/upload", b.handleUpload) + mux.HandleFunc("/api/history", b.handleHistory) srv := &http.Server{ Addr: b.listenAddr, @@ -220,6 +231,42 @@ func (b *Bot) handleChat(w http.ResponseWriter, r *http.Request) { }) } +func (b *Bot) handleHistory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"}) + return + } + if b.historyHandler == nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "history handler not ready"}) + return + } + + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) + if sessionID == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{Error: "session_id is required"}) + return + } + + limitStr := strings.TrimSpace(r.URL.Query().Get("limit")) + limit := 20 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + history, err := b.historyHandler(r.Context(), sessionID, limit) + if err != nil { + if b.log != nil { + b.log.Errorf("webui history handler failed session_id=%s err=%v", sessionID, err) + } + writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "load history failed"}) + return + } + + writeJSON(w, http.StatusOK, history) +} + func firstNonEmpty(vals ...string) string { for _, v := range vals { diff --git a/tools/piplan/piplan.go b/tools/piplan/piplan.go index 030c0b2..2cfa2c8 100644 --- a/tools/piplan/piplan.go +++ b/tools/piplan/piplan.go @@ -86,8 +86,8 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) { output := render(plan) - if len(output) > t.maxOutputChars { - output = output[:t.maxOutputChars] + if len([]rune(output)) > t.maxOutputChars { + output = string([]rune(output)[:t.maxOutputChars]) } return output, nil }