Fix truncation issues in piplan, SQLite storage, and history compression; add PIPlanMaxChars configuration

This commit is contained in:
whlaoding
2026-03-15 00:32:14 +08:00
parent ea88e1dc18
commit 38d6875ab8
8 changed files with 683 additions and 66 deletions

View File

@@ -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) { func(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
return engine.UploadAndCacheFiles(ctx, chatID, userID, files) return engine.UploadAndCacheFiles(ctx, chatID, userID, files)
}, },
func(ctx context.Context, chatID string, limit int) ([]memory.Message, error) {
return engine.GetHistory(chatID, limit)
},
) )
default: default:
return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel) return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel)
@@ -280,6 +283,14 @@ func buildWebUIStreamForwarder(callback webui.StreamEventCallback, exposeReasoni
Content: event.Content, Content: event.Content,
Step: event.Step, 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: default:
return nil return nil
} }

View File

@@ -6,6 +6,7 @@ SKILLS_DIR=./skills
REACT_MAX_STEPS=4 REACT_MAX_STEPS=4
TOOL_CALL_TIMEOUT_SEC=15 TOOL_CALL_TIMEOUT_SEC=15
TOOL_OUTPUT_MAX_CHARS=4000 TOOL_OUTPUT_MAX_CHARS=4000
PI_PLAN_MAX_CHARS=40000
ENABLE_CAPABILITY_GAP=true ENABLE_CAPABILITY_GAP=true
AUTO_SKILL_DIR=./skills AUTO_SKILL_DIR=./skills
GAP_DRAFT_TRIGGER_COUNT=3 GAP_DRAFT_TRIGGER_COUNT=3

View File

@@ -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 `<div>` of `PlanningAgent.tsx` to handle dynamic flex layouts. Use Tailwind's transition utilities for smooth scaling.
```tsx
<div className="flex h-full w-full overflow-hidden bg-surface">
{/* Left Panel: Chat & Controls */}
<div
className={`flex flex-col h-full transition-all duration-300 ease-in-out ${
workspace.isOpen ? 'w-[35%] border-r border-border min-w-[350px]' : 'w-full max-w-5xl mx-auto'
}`}
>
{/* Existing Message List & Input Area */}
</div>
{/* Right Panel: Workspace / Deep Research Output */}
{workspace.isOpen && (
<div className="w-[65%] h-full flex flex-col bg-surface-muted transition-opacity duration-300 animate-fade-in">
{/* Header */}
<div className="h-14 border-b border-border flex items-center px-6 justify-between bg-white">
<h3 className="font-semibold text-txt flex items-center gap-2">
<IconDocument /> {workspace.title || 'Project Planning Document'}
</h3>
{workspace.isGenerating && (
<span className="text-sm text-magenta animate-pulse flex items-center gap-1">
<Spinner /> Generating...
</span>
)}
</div>
{/* Markdown Content Area */}
<div className="flex-1 overflow-y-auto p-8 prose prose-slate max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{workspace.content}
</ReactMarkdown>
</div>
</div>
)}
</div>
```
---
## 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)]
<workspace>
# Project Plan
1. Dev Phase: 1 week
2. Testing Phase: 1 week
</workspace>
[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.

View File

@@ -27,6 +27,9 @@ const (
StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果 StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果
StreamEventTypeFinal StreamEventType = "final" // 最终答案 StreamEventTypeFinal StreamEventType = "final" // 最终答案
StreamEventTypeError StreamEventType = "error" // 错误信息 StreamEventTypeError StreamEventType = "error" // 错误信息
StreamEventTypeWorkspaceStart StreamEventType = "workspace_start" // 工具渲染开始
StreamEventTypeWorkspaceDelta StreamEventType = "workspace_delta" // 工具渲染增量内容
StreamEventTypeWorkspaceEnd StreamEventType = "workspace_end" // 工具渲染结束
) )
// StreamEvent 代表流式输出中的一个事件 // StreamEvent 代表流式输出中的一个事件
@@ -35,6 +38,7 @@ type StreamEvent struct {
Content string `json:"content"` Content string `json:"content"`
Step int `json:"step,omitempty"` Step int `json:"step,omitempty"`
ToolName string `json:"tool_name,omitempty"` ToolName string `json:"tool_name,omitempty"`
WorkspaceTitle string `json:"workspace_title,omitempty"`
} }
// StreamEventCallback 是流式事件回调函数类型,用于推送事件到客户端 // StreamEventCallback 是流式事件回调函数类型,用于推送事件到客户端
@@ -55,10 +59,13 @@ type Orchestrator struct {
gapLookbackDuration time.Duration gapLookbackDuration time.Duration
reactMaxStep int reactMaxStep int
enableCapabilityGap bool enableCapabilityGap bool
autoSkillDraftEnabled bool
log *logger.Logger log *logger.Logger
skillsMu sync.RWMutex skillsMu sync.RWMutex
pendingFilesMu sync.Mutex pendingFilesMu sync.Mutex
pendingFiles map[string][]pendingFileRef pendingFiles map[string][]pendingFileRef
planningSessionsMu sync.Mutex
planningSessions map[string]planningSessionState
} }
type pendingFileRef struct { type pendingFileRef struct {
@@ -67,6 +74,15 @@ type pendingFileRef struct {
MimeType string MimeType string
} }
type planningSessionState struct {
Active bool
LastArtifact string
AwaitingConfirm bool
UpdatedAt time.Time
}
const planningSessionTTL = 2 * time.Hour
// NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。 // NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。
func NewOrchestrator( func NewOrchestrator(
llmClient llm.Client, llmClient llm.Client,
@@ -110,8 +126,10 @@ func NewOrchestrator(
gapLookbackDuration: gapLookbackDuration, gapLookbackDuration: gapLookbackDuration,
reactMaxStep: reactMaxStep, reactMaxStep: reactMaxStep,
enableCapabilityGap: enableCapabilityGap, enableCapabilityGap: enableCapabilityGap,
autoSkillDraftEnabled: false,
log: log, log: log,
pendingFiles: make(map[string][]pendingFileRef), 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) 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供后续同会话文本问答复用。 // UploadAndCacheFiles 上传文件到 LLM 并缓存 file_id供后续同会话文本问答复用。
func (o *Orchestrator) UploadAndCacheFiles(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) { func (o *Orchestrator) UploadAndCacheFiles(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
if len(files) == 0 { if len(files) == 0 {
@@ -342,7 +365,7 @@ func (o *Orchestrator) handleMessageStreamInternal(ctx context.Context, chatID,
// buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。 // buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。
// 工具定义通过 API 的 tools 字段传递;此处只需包含人格、技能、运行环境和思考指引。 // 工具定义通过 API 的 tools 字段传递;此处只需包含人格、技能、运行环境和思考指引。
// routedSkills 为 LLM 路由预选的技能列表;如果为 nil则回退到关键词匹配。 // 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() skillMetaDoc := o.formatSkillSummariesForPrompt()
var relevantSkillsDoc string var relevantSkillsDoc string
if routedSkills != nil { if routedSkills != nil {
@@ -352,6 +375,18 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [
} }
runtimeDoc := formatRuntimeContextForPrompt() 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{ return strings.Join([]string{
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:", "你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
o.soul, o.soul,
@@ -373,6 +408,12 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [
"===== 运行环境 =====", "===== 运行环境 =====",
runtimeDoc, runtimeDoc,
"", "",
"===== 已有结果 (Artifact) =====",
artifactDoc,
"",
"===== PI 规划模式 =====",
planningModeDoc,
"",
"===== 可用技能概览 =====", "===== 可用技能概览 =====",
skillMetaDoc, skillMetaDoc,
"", "",
@@ -383,6 +424,21 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [
}, "\n") }, "\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, "<artifact") {
start := strings.Index(msg.Content, "<artifact")
end := strings.LastIndex(msg.Content, "</artifact>")
if end > start {
return "\n当前正在处理的 Artifact 内容如下:\n" + msg.Content[start:end+11] + "\n如果你需要更新此 Artifact请输出完整的更新后版本并确保包裹在 <artifact> 标签中。"
}
}
}
return "当前没有正在处理的 Artifact。"
}
// routeSkillsWithLLM 使用轻量 LLM 模型对用户输入进行语义路由,判断是否需要加载技能以及选择哪些技能。 // routeSkillsWithLLM 使用轻量 LLM 模型对用户输入进行语义路由,判断是否需要加载技能以及选择哪些技能。
// 返回匹配到的技能列表可能为空切片表示不需要技能nil 表示调用失败应回退)。 // 返回匹配到的技能列表可能为空切片表示不需要技能nil 表示调用失败应回退)。
func (o *Orchestrator) routeSkillsWithLLM(ctx context.Context, userInput string) ([]knowledge.Skill, error) { 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) traceID := logger.TraceIDFromContext(ctx)
traceLogPrefix := "trace_id=" + traceID 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 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) routed, routeErr := o.routeSkillsWithLLM(ctx, userInput)
if routeErr != nil { if routeErr != nil {
if o.log != 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 { if o.log != nil {
o.log.Infof("%s unified react start", traceLogPrefix) 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}) 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 字段传递) // 构建工具定义列表(通过 API tools 字段传递)
toolDefs := o.buildToolDefinitions() toolDefs := o.buildToolDefinitions()
if o.log != nil { if o.log != nil {
@@ -598,6 +687,16 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
for _, tc := range completion.ToolCalls { for _, tc := range completion.ToolCalls {
toolName := strings.ToLower(strings.TrimSpace(tc.Function.Name)) toolName := strings.ToLower(strings.TrimSpace(tc.Function.Name))
toolInput := extractToolInput(tc.Function.Arguments) 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) tool, ok := o.tools.Get(toolName)
if !ok { if !ok {
@@ -624,6 +723,14 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
if obs == "" { if obs == "" {
obs = "(empty output)" 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 { if toolErr != nil {
obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs
o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName) 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) traceID := logger.TraceIDFromContext(ctx)
traceLogPrefix := "trace_id=" + traceID 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 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) routed, routeErr := o.routeSkillsWithLLM(ctx, userInput)
if routeErr != nil { if routeErr != nil {
if o.log != 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 { if o.log != nil {
o.log.Infof("%s unified react stream start", traceLogPrefix) 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, parseCompressedHistoryMessages(compressedContext)...)
messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput}) 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() toolDefs := o.buildToolDefinitions()
if o.log != nil { 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) o.log.Debugf("%s tool_defs_count=%d names=%v", traceLogPrefix, len(toolDefs), toolNames)
} }
workspaceSentThisTurn := false
const maxSteps = 20 const maxSteps = 20
for step := 1; step <= maxSteps; step++ { for step := 1; step <= maxSteps; step++ {
if o.log != nil { if o.log != nil {
@@ -729,10 +878,15 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
} }
} }
// 推送思考过程事件 // 推送思考过程事件(剥离 artifact 标签,避免前端重复渲染工作区)
if completion.Content != "" { if completion.Content != "" {
displayContent := completion.Content
if planningMode {
displayContent = stripArtifactTags(displayContent)
}
if displayContent != "" {
// 分割内容为逐步推送的片段 // 分割内容为逐步推送的片段
segments := splitContentIntoSegments(completion.Content, 50) // 每段50字符 segments := splitContentIntoSegments(displayContent, 50) // 每段50字符
for _, segment := range segments { for _, segment := range segments {
if err := callback(StreamEvent{ if err := callback(StreamEvent{
Type: StreamEventTypeThought, Type: StreamEventTypeThought,
@@ -743,6 +897,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
} }
} }
} }
}
// ========== 无 tool_calls → 最终回答 ========== // ========== 无 tool_calls → 最终回答 ==========
if len(completion.ToolCalls) == 0 { if len(completion.ToolCalls) == 0 {
@@ -750,6 +905,45 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
if finalText == "" { if finalText == "" {
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 { if o.log != nil {
o.log.Debugf("%s react stream final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText)) 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) 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) toolOut, toolErr := tool.Call(ctx, toolInput)
obs := strings.TrimSpace(toolOut) obs := strings.TrimSpace(toolOut)
if obs == "" { if obs == "" {
obs = "(empty output)" 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 { if toolErr != nil {
obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs
o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName) o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName)
@@ -1171,6 +1420,118 @@ func defaultIfEmpty(v, fallback string) string {
return v 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 "<artifact type=\"safe_pi_planning\" title=\"PI Planning Document\">\n" + content + "\n</artifact>"
}
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 返回与当前用户问题最相关的技能内容。 // formatRelevantSkillsForPrompt 返回与当前用户问题最相关的技能内容。
func (o *Orchestrator) formatSelectedSkillsForPrompt(userInput string, selected []knowledge.Skill) string { func (o *Orchestrator) formatSelectedSkillsForPrompt(userInput string, selected []knowledge.Skill) string {
skills := selected skills := selected
@@ -1331,6 +1692,14 @@ func (o *Orchestrator) emitCapabilityGap(chatID, userID, intent, reason string)
return return
} }
// 暂时关闭基于历史能力缺口的自动技能草稿生成。
if !o.autoSkillDraftEnabled {
if o.log != nil {
o.log.Debugf("auto skill draft generation disabled temporarily")
}
return
}
// 提取出高频率缺口并在超出阈值后进行 draft 生成 // 提取出高频率缺口并在超出阈值后进行 draft 生成
clusters, err := o.store.TopCapabilityGapClusters(20, time.Now().UTC().Add(-o.gapLookbackDuration)) clusters, err := o.store.TopCapabilityGapClusters(20, time.Now().UTC().Add(-o.gapLookbackDuration))
if err != nil { if err != nil {
@@ -1573,6 +1942,50 @@ func sanitizeUserFacingAnswer(raw string) string {
return strings.TrimSpace(strings.Join(cleaned, "\n")) return strings.TrimSpace(strings.Join(cleaned, "\n"))
} }
// stripArtifactTags removes <artifact ...>...</artifact> blocks from text,
// returning only the surrounding non-artifact content.
func stripArtifactTags(s string) string {
for {
start := strings.Index(s, "<artifact")
if start == -1 {
break
}
end := strings.Index(s[start:], "</artifact>")
if end == -1 {
s = strings.TrimSpace(s[:start])
break
}
end += start + len("</artifact>")
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 <artifact ...>...</artifact> block.
// Returns empty string if no artifact tags are found.
func extractArtifactContent(s string) string {
start := strings.Index(s, "<artifact")
if start == -1 {
return ""
}
tagEnd := strings.Index(s[start:], ">")
if tagEnd == -1 {
return ""
}
contentStart := start + tagEnd + 1
end := strings.Index(s, "</artifact>")
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). // splitContentIntoSegments splits a string into smaller segments of the specified size (by rune count).
func splitContentIntoSegments(content string, segmentSize int) []string { func splitContentIntoSegments(content string, segmentSize int) []string {
runes := []rune(content) runes := []rune(content)

View File

@@ -18,6 +18,7 @@ type Config struct {
ReactMaxSteps int ReactMaxSteps int
ToolCallTimeoutSec int ToolCallTimeoutSec int
ToolOutputMaxChars int ToolOutputMaxChars int
PIPlanMaxChars int // PI 规划工具专用输出上限,独立于 TOOL_OUTPUT_MAX_CHARS
EnableCapabilityGap bool EnableCapabilityGap bool
AutoSkillDir string AutoSkillDir string
GapDraftTriggerCount int GapDraftTriggerCount int
@@ -95,6 +96,7 @@ func Load() (Config, error) {
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0), ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0),
ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15), ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15),
ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000), ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000),
PIPlanMaxChars: intFromEnv("PI_PLAN_MAX_CHARS", 40000),
EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true), EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true),
AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")), AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3), GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3),
@@ -157,6 +159,9 @@ func Load() (Config, error) {
if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 { if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 {
return Config{}, fmt.Errorf("TOOL_OUTPUT_MAX_CHARS must be between 256 and 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 { if cfg.GapDraftTriggerCount < 1 || cfg.GapDraftTriggerCount > 100 {
return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100") return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100")
} }

View File

@@ -72,7 +72,7 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error
cfg.ToolOutputMaxChars, cfg.ToolOutputMaxChars,
fileDocLog, fileDocLog,
)) ))
registry.Register(piplan.New(cfg.ToolOutputMaxChars, piPlanLog)) registry.Register(piplan.New(cfg.PIPlanMaxChars, piPlanLog))
registry.Register(giteaticket.New( registry.Register(giteaticket.New(
giteaticket.Config{ giteaticket.Config{
BaseURL: cfg.Gitea.BaseURL, BaseURL: cfg.Gitea.BaseURL,

View File

@@ -15,6 +15,8 @@ import (
"laodingbot/internal/config" "laodingbot/internal/config"
"laodingbot/internal/llm" "laodingbot/internal/llm"
"laodingbot/internal/logger" "laodingbot/internal/logger"
"laodingbot/internal/memory"
"strconv"
) )
type IncomingMessage struct { type IncomingMessage struct {
@@ -32,6 +34,10 @@ const (
StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果 StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果
StreamEventTypeFinal StreamEventType = "final" // 最终答案 StreamEventTypeFinal StreamEventType = "final" // 最终答案
StreamEventTypeError StreamEventType = "error" // 错误信息 StreamEventTypeError StreamEventType = "error" // 错误信息
StreamEventTypeWorkspaceStart StreamEventType = "workspace_start" // 工具渲染开始
StreamEventTypeWorkspaceDelta StreamEventType = "workspace_delta" // 工具渲染增量内容
StreamEventTypeWorkspaceEnd StreamEventType = "workspace_end" // 工具渲染结束
) )
// StreamEvent 代表流式输出中的一个事件 // StreamEvent 代表流式输出中的一个事件
@@ -40,12 +46,14 @@ type StreamEvent struct {
Content string `json:"content"` Content string `json:"content"`
Step int `json:"step,omitempty"` Step int `json:"step,omitempty"`
ToolName string `json:"tool_name,omitempty"` ToolName string `json:"tool_name,omitempty"`
WorkspaceTitle string `json:"workspace_title,omitempty"` // 仅用于 workspace_start 类型
} }
type ChatHandler func(context.Context, IncomingMessage) (string, error) type ChatHandler func(context.Context, IncomingMessage) (string, error)
type StreamChatHandler func(context.Context, IncomingMessage, StreamEventCallback) (string, error) type StreamChatHandler func(context.Context, IncomingMessage, StreamEventCallback) (string, error)
type StreamEventCallback func(event StreamEvent) error type StreamEventCallback func(event StreamEvent) error
type UploadHandler func(context.Context, string, string, []llm.InputFile) ([]string, 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 { type Bot struct {
listenAddr string listenAddr string
@@ -55,6 +63,7 @@ type Bot struct {
chatHandler ChatHandler chatHandler ChatHandler
streamChatHandler StreamChatHandler streamChatHandler StreamChatHandler
uploadHandler UploadHandler uploadHandler UploadHandler
historyHandler HistoryHandler
counter uint64 counter uint64
} }
@@ -118,7 +127,7 @@ func NewBot(cfg config.WebUIConfig, log *logger.Logger) (*Bot, error) {
}, nil }, 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 { if chatHandler == nil {
return fmt.Errorf("nil webui chat handler") 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.chatHandler = chatHandler
b.streamChatHandler = streamChatHandler b.streamChatHandler = streamChatHandler
b.uploadHandler = uploadHandler b.uploadHandler = uploadHandler
b.historyHandler = historyHandler
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/chat", b.handleChat) mux.HandleFunc("/api/chat", b.handleChat)
mux.HandleFunc("/api/chat/stream", b.handleChatStream) mux.HandleFunc("/api/chat/stream", b.handleChatStream)
mux.HandleFunc("/api/upload", b.handleUpload) mux.HandleFunc("/api/upload", b.handleUpload)
mux.HandleFunc("/api/history", b.handleHistory)
srv := &http.Server{ srv := &http.Server{
Addr: b.listenAddr, 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 { func firstNonEmpty(vals ...string) string {
for _, v := range vals { for _, v := range vals {

View File

@@ -86,8 +86,8 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
output := render(plan) output := render(plan)
if len(output) > t.maxOutputChars { if len([]rune(output)) > t.maxOutputChars {
output = output[:t.maxOutputChars] output = string([]rune(output)[:t.maxOutputChars])
} }
return output, nil return output, nil
} }