feat: optimize WebUI stream output and sanitize user-facing answers
This commit is contained in:
@@ -220,14 +220,7 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc
|
|||||||
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
||||||
},
|
},
|
||||||
func(ctx context.Context, msg webui.IncomingMessage, callback webui.StreamEventCallback) (string, error) {
|
func(ctx context.Context, msg webui.IncomingMessage, callback webui.StreamEventCallback) (string, error) {
|
||||||
return engine.HandleMessageStream(ctx, msg.ChatID, msg.UserID, msg.Text, func(event agent.StreamEvent) error {
|
return engine.HandleMessageStream(ctx, msg.ChatID, msg.UserID, msg.Text, buildWebUIStreamForwarder(callback))
|
||||||
return callback(webui.StreamEvent{
|
|
||||||
Type: webui.StreamEventType(event.Type),
|
|
||||||
Content: event.Content,
|
|
||||||
Step: event.Step,
|
|
||||||
ToolName: event.ToolName,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
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)
|
||||||
@@ -237,3 +230,20 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc
|
|||||||
return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel)
|
return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildWebUIStreamForwarder(callback webui.StreamEventCallback) agent.StreamEventCallback {
|
||||||
|
return func(event agent.StreamEvent) error {
|
||||||
|
if callback == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch event.Type {
|
||||||
|
case agent.StreamEventTypeFinal, agent.StreamEventTypeError:
|
||||||
|
return callback(webui.StreamEvent{
|
||||||
|
Type: webui.StreamEventType(event.Type),
|
||||||
|
Content: event.Content,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
43
cmd/bot/main_test.go
Normal file
43
cmd/bot/main_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"laodingbot/internal/agent"
|
||||||
|
"laodingbot/internal/transport/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildWebUIStreamForwarderFiltersTraceEvents(t *testing.T) {
|
||||||
|
var got []webui.StreamEvent
|
||||||
|
forwarder := buildWebUIStreamForwarder(func(event webui.StreamEvent) error {
|
||||||
|
got = append(got, event)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
events := []agent.StreamEvent{
|
||||||
|
{Type: agent.StreamEventTypeThought, Content: "thinking", Step: 1},
|
||||||
|
{Type: agent.StreamEventTypeToolCall, Content: "pwd", Step: 1, ToolName: "shell"},
|
||||||
|
{Type: agent.StreamEventTypeToolResult, Content: "C:/Project", Step: 1, ToolName: "shell"},
|
||||||
|
{Type: agent.StreamEventTypeFinal, Content: "done", Step: 2},
|
||||||
|
{Type: agent.StreamEventTypeError, Content: "boom", Step: 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, event := range events {
|
||||||
|
if err := forwarder(event); err != nil {
|
||||||
|
t.Fatalf("forwarder returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 forwarded events, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Type != webui.StreamEventTypeFinal || got[0].Content != "done" {
|
||||||
|
t.Fatalf("unexpected final event: %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[0].Step != 0 || got[0].ToolName != "" {
|
||||||
|
t.Fatalf("expected final event without trace fields, got %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Type != webui.StreamEventTypeError || got[1].Content != "boom" {
|
||||||
|
t.Fatalf("unexpected error event: %+v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,12 +57,14 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
|
|||||||
|
|
||||||
事件类型:
|
事件类型:
|
||||||
|
|
||||||
- `thought`: 模型思考过程
|
|
||||||
- `tool_call`: 工具调用请求
|
|
||||||
- `tool_result`: 工具返回结果
|
|
||||||
- `final`: 最终回答
|
- `final`: 最终回答
|
||||||
- `error`: 错误信息
|
- `error`: 错误信息
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 当前 WebUI 默认只向前端返回 `final` 和 `error`。
|
||||||
|
- 中间推理轨迹(如 `thought`、`tool_call`、`tool_result` 以及对应的 `step`)会写入服务端 `debug` 日志,不再直接返回给用户界面。
|
||||||
|
|
||||||
## 4. 连接生命周期
|
## 4. 连接生命周期
|
||||||
|
|
||||||
- 正常结束: 收到 `type=final` 后结束渲染,连接可由浏览器自然关闭。
|
- 正常结束: 收到 `type=final` 后结束渲染,连接可由浏览器自然关闭。
|
||||||
@@ -71,10 +73,8 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
|
|||||||
|
|
||||||
## 5. 前端渲染建议
|
## 5. 前端渲染建议
|
||||||
|
|
||||||
推荐将一次请求的事件按 `step` 分组后渲染,典型展示区块:
|
推荐前端仅处理两类结果:
|
||||||
|
|
||||||
- 思考区: 逐条显示 `thought`
|
|
||||||
- 工具区: 成对显示 `tool_call` 与 `tool_result`
|
|
||||||
- 答案区: 显示最后一个 `final`
|
- 答案区: 显示最后一个 `final`
|
||||||
- 错误区: 显示 `error`
|
- 错误区: 显示 `error`
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
|
|||||||
## 6. TypeScript 对接示例 (fetch + ReadableStream)
|
## 6. TypeScript 对接示例 (fetch + ReadableStream)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type StreamEventType = 'thought' | 'tool_call' | 'tool_result' | 'final' | 'error';
|
type StreamEventType = 'final' | 'error';
|
||||||
|
|
||||||
interface StreamEvent {
|
interface StreamEvent {
|
||||||
type: StreamEventType;
|
type: StreamEventType;
|
||||||
@@ -165,6 +165,7 @@ export async function streamChat(
|
|||||||
- step?: number
|
- step?: number
|
||||||
- tool_name?: string
|
- tool_name?: string
|
||||||
5) 收到 final 视为本轮完成;收到 error 视为失败
|
5) 收到 final 视为本轮完成;收到 error 视为失败
|
||||||
|
6) 不要再假设前端会收到 thought/tool_call/tool_result;这些内部轨迹已改为服务端 debug 日志
|
||||||
|
|
||||||
你的改造要求:
|
你的改造要求:
|
||||||
1) 保留现有 UI 风格和组件结构,不做无关重构
|
1) 保留现有 UI 风格和组件结构,不做无关重构
|
||||||
|
|||||||
@@ -358,16 +358,17 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [
|
|||||||
"",
|
"",
|
||||||
"===== ReAct 思考指引 =====",
|
"===== ReAct 思考指引 =====",
|
||||||
"你采用 ReAct(Reasoning + Acting)模式进行任务处理。",
|
"你采用 ReAct(Reasoning + Acting)模式进行任务处理。",
|
||||||
"1. 思考优先:在做出任何行动之前,先在回复中阐述你的推理过程(Thought)。",
|
"1. 思考优先:在做出任何行动之前,先完成内部推理,但不要把 Thought、trace、step 暴露给用户。",
|
||||||
"2. 工具调用:如果需要获取信息或执行操作,使用提供的工具函数(function calling)进行调用。",
|
"2. 工具调用:如果需要获取信息或执行操作,使用提供的工具函数(function calling)进行调用。",
|
||||||
"3. 观察反馈:检查工具返回的结果,据此决定下一步行动。",
|
"3. 观察反馈:检查工具返回的结果,据此决定下一步行动。",
|
||||||
"4. 最终回答:当你有足够信息时,直接给出面向用户的最终文本回复,不要调用工具。",
|
"4. 最终回答:当你有足够信息时,只输出面向用户的最终文本回复,不要附带推理轨迹,不要调用工具。",
|
||||||
"",
|
"",
|
||||||
"注意事项:",
|
"注意事项:",
|
||||||
"- 每次要么调用工具,要么给出最终回答,不要两者都做。",
|
"- 每次要么调用工具,要么给出最终回答,不要两者都做。",
|
||||||
"- 如果工具调用失败,根据错误信息(Traceback)调整策略后重试或给出替代方案。",
|
"- 如果工具调用失败,根据错误信息(Traceback)调整策略后重试或给出替代方案。",
|
||||||
"- 涉及文件、目录、命令时,优先调用工具获取真实结果,不要猜测。",
|
"- 涉及文件、目录、命令时,优先调用工具获取真实结果,不要猜测。",
|
||||||
"- 你的思考过程(Thought)应写在回复内容中,帮助追踪推理逻辑。",
|
"- 如果本轮需要调用工具,可以在 assistant content 中写简短内部推理,供系统记录日志;这些内容不会直接展示给用户。",
|
||||||
|
"- 最终用户可见内容中禁止出现 Thought、Trace、Step、Observation、Action、ActionInput 等字段或标题。",
|
||||||
"",
|
"",
|
||||||
"===== 运行环境 =====",
|
"===== 运行环境 =====",
|
||||||
runtimeDoc,
|
runtimeDoc,
|
||||||
@@ -556,7 +557,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
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 {
|
||||||
o.log.Infof("%s react step=%d start messages_count=%d", traceLogPrefix, step, len(messages))
|
o.log.Debugf("%s react step=%d start messages_count=%d", traceLogPrefix, step, len(messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用 LLM(传入完整 messages + tools 定义)
|
// 调用 LLM(传入完整 messages + tools 定义)
|
||||||
@@ -566,7 +567,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react step=%d content_len=%d tool_calls=%d",
|
o.log.Debugf("%s react step=%d content_len=%d tool_calls=%d",
|
||||||
traceLogPrefix, step, len(completion.Content), len(completion.ToolCalls))
|
traceLogPrefix, step, len(completion.Content), len(completion.ToolCalls))
|
||||||
if completion.Content != "" {
|
if completion.Content != "" {
|
||||||
o.log.Debugf("%s react step=%d thought=%q", traceLogPrefix, step, completion.Content)
|
o.log.Debugf("%s react step=%d thought=%q", traceLogPrefix, step, completion.Content)
|
||||||
@@ -575,12 +576,12 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
|
|
||||||
// ========== 无 tool_calls → 最终回答 ==========
|
// ========== 无 tool_calls → 最终回答 ==========
|
||||||
if len(completion.ToolCalls) == 0 {
|
if len(completion.ToolCalls) == 0 {
|
||||||
finalText := strings.TrimSpace(completion.Content)
|
finalText := sanitizeUserFacingAnswer(completion.Content)
|
||||||
if finalText == "" {
|
if finalText == "" {
|
||||||
finalText = "已完成处理。"
|
finalText = "已完成处理。"
|
||||||
}
|
}
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText))
|
o.log.Debugf("%s react final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText))
|
||||||
}
|
}
|
||||||
return finalText, nil
|
return finalText, nil
|
||||||
}
|
}
|
||||||
@@ -614,7 +615,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput))
|
o.log.Debugf("%s react step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput))
|
||||||
o.log.Debugf("%s react step=%d tool=%s input=%q", traceLogPrefix, step, toolName, toolInput)
|
o.log.Debugf("%s react step=%d tool=%s input=%q", traceLogPrefix, step, toolName, toolInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +634,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs))
|
o.log.Debugf("%s react step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs))
|
||||||
o.log.Debugf("%s react step=%d tool=%s observation=%q", traceLogPrefix, step, toolName, truncateForLog(obs, 500))
|
o.log.Debugf("%s react step=%d tool=%s observation=%q", traceLogPrefix, step, toolName, truncateForLog(obs, 500))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,7 +712,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
|||||||
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 {
|
||||||
o.log.Infof("%s react stream step=%d start messages_count=%d", traceLogPrefix, step, len(messages))
|
o.log.Debugf("%s react stream step=%d start messages_count=%d", traceLogPrefix, step, len(messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用 LLM
|
// 调用 LLM
|
||||||
@@ -721,7 +722,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
|||||||
}
|
}
|
||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react stream step=%d content_len=%d tool_calls=%d",
|
o.log.Debugf("%s react stream step=%d content_len=%d tool_calls=%d",
|
||||||
traceLogPrefix, step, len(completion.Content), len(completion.ToolCalls))
|
traceLogPrefix, step, len(completion.Content), len(completion.ToolCalls))
|
||||||
if completion.Content != "" {
|
if completion.Content != "" {
|
||||||
o.log.Debugf("%s react stream step=%d thought=%q", traceLogPrefix, step, completion.Content)
|
o.log.Debugf("%s react stream step=%d thought=%q", traceLogPrefix, step, completion.Content)
|
||||||
@@ -741,12 +742,12 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
|||||||
|
|
||||||
// ========== 无 tool_calls → 最终回答 ==========
|
// ========== 无 tool_calls → 最终回答 ==========
|
||||||
if len(completion.ToolCalls) == 0 {
|
if len(completion.ToolCalls) == 0 {
|
||||||
finalText := strings.TrimSpace(completion.Content)
|
finalText := sanitizeUserFacingAnswer(completion.Content)
|
||||||
if finalText == "" {
|
if finalText == "" {
|
||||||
finalText = "已完成处理。"
|
finalText = "已完成处理。"
|
||||||
}
|
}
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%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))
|
||||||
}
|
}
|
||||||
// 推送最终答案事件
|
// 推送最终答案事件
|
||||||
if err := callback(StreamEvent{
|
if err := callback(StreamEvent{
|
||||||
@@ -808,7 +809,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
|||||||
}
|
}
|
||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react stream step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput))
|
o.log.Debugf("%s react stream step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput))
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,7 +828,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
|||||||
}
|
}
|
||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react stream step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs))
|
o.log.Debugf("%s react stream step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs))
|
||||||
o.log.Debugf("%s react stream step=%d tool=%s observation=%q", traceLogPrefix, step, toolName, truncateForLog(obs, 500))
|
o.log.Debugf("%s react stream step=%d tool=%s observation=%q", traceLogPrefix, step, toolName, truncateForLog(obs, 500))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,7 +873,7 @@ func (o *Orchestrator) runLegacyReAct(ctx context.Context, chatID, userID, compr
|
|||||||
|
|
||||||
for step := 1; step <= maxSteps; step++ {
|
for step := 1; step <= maxSteps; step++ {
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s legacy react step=%d start", traceLogPrefix, step)
|
o.log.Debugf("%s legacy react step=%d start", traceLogPrefix, step)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := buildReActMessages(systemPrompt, compressedContext, userInput, scratchpad)
|
messages := buildReActMessages(systemPrompt, compressedContext, userInput, scratchpad)
|
||||||
@@ -890,10 +891,10 @@ func (o *Orchestrator) runLegacyReAct(ctx context.Context, chatID, userID, compr
|
|||||||
if decision.IsFinalAnswer {
|
if decision.IsFinalAnswer {
|
||||||
finalText := ""
|
finalText := ""
|
||||||
if decision.FinalAnswer != nil {
|
if decision.FinalAnswer != nil {
|
||||||
finalText = strings.TrimSpace(*decision.FinalAnswer)
|
finalText = sanitizeUserFacingAnswer(*decision.FinalAnswer)
|
||||||
}
|
}
|
||||||
if finalText == "" {
|
if finalText == "" {
|
||||||
finalText = strings.TrimSpace(decision.Thought)
|
finalText = sanitizeUserFacingAnswer(decision.Thought)
|
||||||
}
|
}
|
||||||
if finalText == "" {
|
if finalText == "" {
|
||||||
finalText = "已完成处理。"
|
finalText = "已完成处理。"
|
||||||
@@ -1518,3 +1519,52 @@ func truncateForLog(s string, maxLen int) string {
|
|||||||
}
|
}
|
||||||
return s[:maxLen] + "...(truncated)"
|
return s[:maxLen] + "...(truncated)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeUserFacingAnswer(raw string) string {
|
||||||
|
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
markers := []string{"Final Answer:", "Final Answer:", "最终回答:", "最终回答:", "最终答案:", "最终答案:", "Answer:", "Answer:"}
|
||||||
|
for _, marker := range markers {
|
||||||
|
idx := strings.LastIndex(raw, marker)
|
||||||
|
if idx >= 0 {
|
||||||
|
candidate := strings.TrimSpace(raw[idx+len(marker):])
|
||||||
|
if candidate != "" {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
cleaned := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
if len(cleaned) > 0 && cleaned[len(cleaned)-1] != "" {
|
||||||
|
cleaned = append(cleaned, "")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.HasPrefix(lower, "thought:") || strings.HasPrefix(lower, "trace:") || strings.HasPrefix(lower, "observation:") ||
|
||||||
|
strings.HasPrefix(lower, "action:") || strings.HasPrefix(lower, "actioninput:") || strings.HasPrefix(lower, "action input:") ||
|
||||||
|
strings.HasPrefix(lower, "step ") || strings.HasPrefix(trimmed, "思考:") || strings.HasPrefix(trimmed, "思考:") ||
|
||||||
|
strings.HasPrefix(trimmed, "推理:") || strings.HasPrefix(trimmed, "推理:") || strings.HasPrefix(trimmed, "观察:") ||
|
||||||
|
strings.HasPrefix(trimmed, "观察:") || strings.HasPrefix(trimmed, "行动:") || strings.HasPrefix(trimmed, "行动:") ||
|
||||||
|
strings.HasPrefix(trimmed, "步骤 ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(cleaned) > 0 && cleaned[len(cleaned)-1] == "" {
|
||||||
|
cleaned = cleaned[:len(cleaned)-1]
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(cleaned, "\n"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,3 +93,32 @@ func TestMatchSkillsByNameEmpty(t *testing.T) {
|
|||||||
t.Fatalf("expected 0 matches, got %d", len(matched))
|
t.Fatalf("expected 0 matches, got %d", len(matched))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeUserFacingAnswerExtractsFinalAnswer(t *testing.T) {
|
||||||
|
raw := "Thought: 先分析用户问题\nObservation: 已经有足够信息\nFinal Answer: 这是给用户的结果"
|
||||||
|
got := sanitizeUserFacingAnswer(raw)
|
||||||
|
if got != "这是给用户的结果" {
|
||||||
|
t.Fatalf("expected final answer only, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeUserFacingAnswerDropsTraceLines(t *testing.T) {
|
||||||
|
raw := strings.Join([]string{
|
||||||
|
"Step 1 Thought: 检查上下文",
|
||||||
|
"Action: shell",
|
||||||
|
"Observation: ok",
|
||||||
|
"请执行以下变更。",
|
||||||
|
}, "\n")
|
||||||
|
got := sanitizeUserFacingAnswer(raw)
|
||||||
|
if got != "请执行以下变更。" {
|
||||||
|
t.Fatalf("expected user-facing text only, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeUserFacingAnswerKeepsNormalAnswer(t *testing.T) {
|
||||||
|
raw := "1. 先打开配置文件\n2. 修改端口后重启服务"
|
||||||
|
got := sanitizeUserFacingAnswer(raw)
|
||||||
|
if got != raw {
|
||||||
|
t.Fatalf("expected answer unchanged, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,9 +81,10 @@ type InputFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OpenAICompatibleClient struct {
|
type OpenAICompatibleClient struct {
|
||||||
client openai.Client
|
client openai.Client
|
||||||
model string
|
model string
|
||||||
log *logger.Logger
|
disableThinkingParam bool
|
||||||
|
log *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient {
|
func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient {
|
||||||
@@ -95,9 +96,10 @@ func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAI
|
|||||||
opts = append(opts, option.WithBaseURL(cfg.BaseURL))
|
opts = append(opts, option.WithBaseURL(cfg.BaseURL))
|
||||||
}
|
}
|
||||||
return &OpenAICompatibleClient{
|
return &OpenAICompatibleClient{
|
||||||
client: openai.NewClient(opts...),
|
client: openai.NewClient(opts...),
|
||||||
model: cfg.Model,
|
model: cfg.Model,
|
||||||
log: log,
|
disableThinkingParam: shouldDisableThinkingParam(cfg.BaseURL),
|
||||||
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +140,7 @@ func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Chat.Completions.New(ctx, params)
|
resp, err := c.client.Chat.Completions.New(ctx, params, c.chatCompletionRequestOptions()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("llm tool-call request failed: %w", err)
|
return nil, fmt.Errorf("llm tool-call request failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -180,7 +182,7 @@ func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Contex
|
|||||||
Messages: sdkMessages,
|
Messages: sdkMessages,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Chat.Completions.New(ctx, params)
|
resp, err := c.client.Chat.Completions.New(ctx, params, c.chatCompletionRequestOptions()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Errorf("llm request failed err=%v", err)
|
c.log.Errorf("llm request failed err=%v", err)
|
||||||
@@ -392,3 +394,18 @@ func appendIfMissing(items []string, value string) []string {
|
|||||||
}
|
}
|
||||||
return append(items, value)
|
return append(items, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *OpenAICompatibleClient) chatCompletionRequestOptions() []option.RequestOption {
|
||||||
|
if !c.disableThinkingParam {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []option.RequestOption{option.WithJSONSet("enable_thinking", false)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldDisableThinkingParam(baseURL string) bool {
|
||||||
|
baseURL = strings.ToLower(strings.TrimSpace(baseURL))
|
||||||
|
if baseURL == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(baseURL, "dashscope.aliyuncs.com")
|
||||||
|
}
|
||||||
|
|||||||
24
internal/llm/client_test.go
Normal file
24
internal/llm/client_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShouldDisableThinkingParam(t *testing.T) {
|
||||||
|
if !shouldDisableThinkingParam("https://dashscope.aliyuncs.com/compatible-mode/v1") {
|
||||||
|
t.Fatal("expected DashScope base URL to require enable_thinking=false")
|
||||||
|
}
|
||||||
|
if shouldDisableThinkingParam("https://api.openai.com/v1") {
|
||||||
|
t.Fatal("expected standard OpenAI base URL not to require enable_thinking=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatCompletionRequestOptions(t *testing.T) {
|
||||||
|
client := &OpenAICompatibleClient{disableThinkingParam: true}
|
||||||
|
if got := len(client.chatCompletionRequestOptions()); got != 1 {
|
||||||
|
t.Fatalf("expected 1 request option when disableThinkingParam=true, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.disableThinkingParam = false
|
||||||
|
if got := len(client.chatCompletionRequestOptions()); got != 0 {
|
||||||
|
t.Fatalf("expected 0 request options when disableThinkingParam=false, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,13 +140,15 @@ description: 扮演 SAFe 铁三角(PM、架构师、RTE),将宏观 Epic
|
|||||||
|
|
||||||
**Observation**: 获取渲染后的 Markdown 架构蓝图,包含愿景、特性清单、Enabler 表、NFRs、依赖关系、执行顺序和质量门禁检查清单。
|
**Observation**: 获取渲染后的 Markdown 架构蓝图,包含愿景、特性清单、Enabler 表、NFRs、依赖关系、执行顺序和质量门禁检查清单。
|
||||||
|
|
||||||
将此蓝图**完整展示给用户**,征求反馈。
|
**⚠️ 关键要求**:工具返回的内容(Observation)不会直接展示给用户。你**必须**将 `publish_pi_plan` 返回的蓝图 Markdown **全文**复制到你的最终回复中,让用户可以看到完整的规划内容。**严禁**仅用一句"蓝图已生成"代替正文输出。
|
||||||
|
|
||||||
|
输出蓝图后,征求用户反馈。如果用户没有异议,直接继续执行阶段 3。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 阶段 3:任务下发到 Gitea(用户确认后)
|
### 阶段 3:任务下发到 Gitea
|
||||||
|
|
||||||
当用户确认规划方案后,将 Feature 和 Enabler **逐一拆解为 User Story**,通过 `create_gitea_ticket` 在 Gitea 创建工单。
|
蓝图展示给用户后,将 Feature 和 Enabler **逐一拆解为 User Story**,通过 `create_gitea_ticket` 在 Gitea 创建工单。如果用户明确表示需要调整,先根据反馈修订蓝图后再创建工单。
|
||||||
|
|
||||||
#### 拆解原则
|
#### 拆解原则
|
||||||
|
|
||||||
@@ -215,13 +217,20 @@ Thought:
|
|||||||
|
|
||||||
Action: publish_pi_plan
|
Action: publish_pi_plan
|
||||||
Action Input: {"pi_vision": "...", "features": [...], ...}
|
Action Input: {"pi_vision": "...", "features": [...], ...}
|
||||||
|
|
||||||
|
Observation: (工具返回完整 Markdown 蓝图)
|
||||||
|
|
||||||
|
Thought: 蓝图已生成。由于 Observation 不会直接展示给用户,我必须在 Final Answer 中包含蓝图全文内容。同时继续执行阶段 3,创建 Gitea 工单。
|
||||||
|
|
||||||
|
Final Answer:
|
||||||
|
(此处粘贴 publish_pi_plan 返回的完整蓝图 Markdown 全文)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. 输出规范
|
## 5. 输出规范
|
||||||
|
|
||||||
最终交付给用户的内容必须包含:
|
最终交付给用户的内容必须包含:
|
||||||
|
|
||||||
1. **PI 蓝图**:`publish_pi_plan` 生成的完整 Markdown 报告
|
1. **PI 蓝图**:`publish_pi_plan` 返回的完整 Markdown 报告(必须全文输出,不可省略或仅用一句话概括)
|
||||||
2. **Gitea 工单汇总**(如已执行阶段 3):
|
2. **Gitea 工单汇总**(如已执行阶段 3):
|
||||||
- 工单编号与链接列表
|
- 工单编号与链接列表
|
||||||
- 按执行顺序排列
|
- 按执行顺序排列
|
||||||
|
|||||||
Reference in New Issue
Block a user