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
}