diff --git a/agent/assistant/search.go b/agent/assistant/search.go index f6164a63..6e07bd72 100644 --- a/agent/assistant/search.go +++ b/agent/assistant/search.go @@ -1,12 +1,16 @@ package assistant import ( + "fmt" "strings" "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/i18n" + "github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/search" "github.com/yaoapp/yao/agent/search/nlp/keyword" searchTypes "github.com/yaoapp/yao/agent/search/types" + traceTypes "github.com/yaoapp/yao/trace/types" ) // shouldAutoSearch determines if auto search should be executed @@ -136,6 +140,12 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context return nil } + // === Output: Send loading message === + loadingID := ast.sendSearchLoading(ctx) + + // === Trace: Create search trace node === + searchNode := ast.createSearchTrace(ctx, query, requests) + // Execute searches in parallel ctx.Logger.Info("Executing %d search requests for query: %s", len(requests), truncateString(query, 50)) @@ -143,6 +153,12 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context if err != nil { // Log error but don't fail - search errors shouldn't block the main flow ctx.Logger.Error("Auto search failed: %v", err) + + // === Output: Send failed message === + ast.sendSearchDone(ctx, loadingID, 0, true) + + // === Trace: Mark as failed === + ast.completeSearchTrace(searchNode, 0, err) return nil } @@ -153,15 +169,179 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context } refCtx := search.BuildReferenceContext(results, citationConfig) - if len(refCtx.References) == 0 { + resultCount := len(refCtx.References) + + // === Output: Send result message, then done === + ast.sendSearchResult(ctx, loadingID, resultCount) + ast.sendSearchDone(ctx, loadingID, resultCount, false) + + // === Trace: Mark as completed === + ast.completeSearchTrace(searchNode, resultCount, nil) + + if resultCount == 0 { ctx.Logger.Info("No search results found") return nil } - ctx.Logger.Info("Auto search completed: %d references", len(refCtx.References)) + ctx.Logger.Info("Auto search completed: %d references", resultCount) return refCtx } +// ============================================================================ +// Output: Loading Replace Pattern +// ============================================================================ + +// sendSearchLoading sends the initial loading message +// Returns the message ID for later replacement +func (ast *Assistant) sendSearchLoading(ctx *context.Context) string { + loadingMsg := i18n.T(ctx.Locale, "search.loading") + + msg := &message.Message{ + Type: "loading", + Props: map[string]any{ + "message": loadingMsg, + }, + } + + // Send and get message ID + msgID, err := ctx.SendStream(msg) + if err != nil { + ctx.Logger.Warn("Failed to send search loading message: %v", err) + return "" + } + + return msgID +} + +// sendSearchResult replaces loading with result message (without done flag) +func (ast *Assistant) sendSearchResult(ctx *context.Context, loadingID string, count int) { + if loadingID == "" { + return + } + + var resultMsg string + if count == 0 { + resultMsg = i18n.T(ctx.Locale, "search.no_results") + } else if count == 1 { + resultMsg = i18n.T(ctx.Locale, "search.success.one") + } else { + resultMsg = fmt.Sprintf(i18n.T(ctx.Locale, "search.success"), count) + } + + msg := &message.Message{ + MessageID: loadingID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: "loading", + Props: map[string]any{ + "message": resultMsg, + }, + } + + if err := ctx.Send(msg); err != nil { + ctx.Logger.Warn("Failed to send search result message: %v", err) + } +} + +// sendSearchDone sends the final done message (removes loading indicator) +func (ast *Assistant) sendSearchDone(ctx *context.Context, loadingID string, count int, failed bool) { + if loadingID == "" { + return + } + + var resultMsg string + if failed { + resultMsg = i18n.T(ctx.Locale, "search.failed") + } else if count == 0 { + resultMsg = i18n.T(ctx.Locale, "search.no_results") + } else if count == 1 { + resultMsg = i18n.T(ctx.Locale, "search.success.one") + } else { + resultMsg = fmt.Sprintf(i18n.T(ctx.Locale, "search.success"), count) + } + + msg := &message.Message{ + MessageID: loadingID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: "loading", + Props: map[string]any{ + "message": resultMsg, + "done": true, // Frontend will remove loading indicator + }, + } + + if err := ctx.Send(msg); err != nil { + ctx.Logger.Warn("Failed to send search done message: %v", err) + } +} + +// ============================================================================ +// Trace: Search Node +// ============================================================================ + +// createSearchTrace creates a trace node for search operation +func (ast *Assistant) createSearchTrace(ctx *context.Context, query string, requests []*searchTypes.Request) traceTypes.Node { + trace, _ := ctx.Trace() + if trace == nil { + return nil + } + + // Build search types list + var searchTypes []string + for _, req := range requests { + searchTypes = append(searchTypes, string(req.Type)) + } + + input := map[string]any{ + "query": query, + "types": searchTypes, + } + + node, err := trace.Add(input, traceTypes.TraceNodeOption{ + Label: i18n.T(ctx.Locale, "search.trace.label"), + Type: "search", + Icon: "search", + Description: i18n.T(ctx.Locale, "search.trace.description"), + }) + + if err != nil { + ctx.Logger.Warn("Failed to create search trace node: %v", err) + return nil + } + + // Log search start + node.Info("Starting search", map[string]any{ + "query": query, + "types": searchTypes, + }) + + return node +} + +// completeSearchTrace marks the search trace node as completed or failed +func (ast *Assistant) completeSearchTrace(node traceTypes.Node, resultCount int, err error) { + if node == nil { + return + } + + if err != nil { + node.Warn("Search failed", map[string]any{"error": err.Error()}) + node.Fail(err) + return + } + + // Log completion + node.Info("Search completed", map[string]any{ + "result_count": resultCount, + }) + + // Complete with output + node.Complete(map[string]any{ + "result_count": resultCount, + }) +} + // buildSearchRequests builds search requests based on assistant configuration func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Config) []*searchTypes.Request { var requests []*searchTypes.Request diff --git a/agent/i18n/builtin.go b/agent/i18n/builtin.go index b912d798..7fd07a77 100644 --- a/agent/i18n/builtin.go +++ b/agent/i18n/builtin.go @@ -98,6 +98,24 @@ func init() { // KB: Chat collection "kb.chat.name": "Chat Knowledge Base", "kb.chat.description": "Auto-created knowledge base collection for chat sessions", + + // Search: assistant/search.go - Output messages + "search.loading": "Searching...", + "search.success": "Found %d references", + "search.success.one": "Found 1 reference", + "search.partial": "Found %d references (some sources failed)", + "search.failed": "Search failed", + "search.no_results": "No references found", + + // Search: assistant/search.go - Trace labels + "search.trace.label": "Search", + "search.trace.description": "Search the web and knowledge base for relevant information", + "search.trace.web.label": "Web Search", + "search.trace.web.description": "Searching the web", + "search.trace.kb.label": "KB Search", + "search.trace.kb.description": "Searching knowledge base", + "search.trace.db.label": "DB Search", + "search.trace.db.description": "Searching database", }, } @@ -164,6 +182,24 @@ func init() { // KB: Chat collection "kb.chat.name": "聊天知识库", "kb.chat.description": "自动为聊天会话创建的知识库集合", + + // Search: assistant/search.go - Output messages + "search.loading": "正在搜索...", + "search.success": "找到 %d 条参考资料", + "search.success.one": "找到 1 条参考资料", + "search.partial": "找到 %d 条参考资料(部分来源失败)", + "search.failed": "搜索失败", + "search.no_results": "未找到相关资料", + + // Search: assistant/search.go - Trace labels + "search.trace.label": "搜索", + "search.trace.description": "搜索网络和知识库获取相关信息", + "search.trace.web.label": "网页搜索", + "search.trace.web.description": "搜索网页获取相关信息", + "search.trace.kb.label": "知识库搜索", + "search.trace.kb.description": "搜索知识库获取相关信息", + "search.trace.db.label": "数据库搜索", + "search.trace.db.description": "搜索数据库获取相关信息", }, } @@ -258,6 +294,24 @@ func init() { // KB: Chat collection "kb.chat.name": "聊天知识库", "kb.chat.description": "自动为聊天会话创建的知识库集合", + + // Search: assistant/search.go - Output messages + "search.loading": "正在搜索...", + "search.success": "找到 %d 条参考资料", + "search.success.one": "找到 1 条参考资料", + "search.partial": "找到 %d 条参考资料(部分来源失败)", + "search.failed": "搜索失败", + "search.no_results": "未找到相关资料", + + // Search: assistant/search.go - Trace labels + "search.trace.label": "搜索", + "search.trace.description": "搜索网络和知识库获取相关信息", + "search.trace.web.label": "网页搜索", + "search.trace.web.description": "搜索网页获取相关信息", + "search.trace.kb.label": "知识库搜索", + "search.trace.kb.description": "搜索知识库获取相关信息", + "search.trace.db.label": "数据库搜索", + "search.trace.db.description": "搜索数据库获取相关信息", }, } } diff --git a/agent/search/DESIGN.md b/agent/search/DESIGN.md index 077a3a69..3331cfb4 100644 --- a/agent/search/DESIGN.md +++ b/agent/search/DESIGN.md @@ -155,7 +155,7 @@ agent/search/ │ │ ├── builtin.go # Builtin frequency-based extraction │ │ ├── agent.go # Agent mode (LLM-powered) │ │ └── mcp.go # MCP mode (external service) -│ └── querydsl/ # QueryDSL generation for DB search (待实现) +│ └── querydsl/ # QueryDSL generation for DB search (TODO) │ ├── generator.go # Main generator (mode dispatch) │ ├── builtin.go # Builtin template-based generation │ ├── agent.go # Agent mode (LLM-powered) @@ -171,22 +171,21 @@ agent/search/ │ │ ├── agent.go # Agent mode (AI Search) │ │ └── mcp.go # MCP mode (external service) │ │ -│ ├── kb/ # Knowledge base search (骨架) +│ ├── kb/ # Knowledge base search (skeleton) │ │ ├── handler.go # KB search handler -│ │ ├── vector.go # Vector similarity search (待实现) -│ │ └── graph.go # Graph-based association (待实现) +│ │ ├── vector.go # Vector similarity search (TODO) +│ │ └── graph.go # Graph-based association (TODO) │ │ -│ └── db/ # Database search (骨架) +│ └── db/ # Database search (skeleton) │ ├── handler.go # DB search handler -│ ├── query.go # QueryDSL builder (待实现) -│ └── schema.go # Model schema introspection (待实现) +│ ├── query.go # QueryDSL builder (TODO) +│ └── schema.go # Model schema introspection (TODO) │ └── defaults/ # Default configuration values └── defaults.go # System built-in defaults (used by agent/load.go) -# 待实现文件: -# - trace.go # Trace node creation and management -# - output.go # Real-time output/streaming to client +# Note: Output and Trace are integrated into assistant/search.go +# No separate trace.go or output.go files needed ``` ### Dependency Graph @@ -836,51 +835,177 @@ search: ## Trace Integration -Search operations create trace nodes to report execution details to users, providing transparency about what the agent is doing. +Search operations create minimal trace nodes to report execution status to users, providing transparency about what the agent is doing. Detailed information is recorded via LOG for debugging. ### Trace Node Structure +Uses `trace/types.NodeStatus` constants: + +- `pending` - Node created but not started +- `running` - Node is currently executing +- `completed` - Node finished successfully +- `failed` - Node failed with error + +**Single Search:** + ``` search (type: "search") -├── query // Original query -├── search_type // "web", "kb", or "db" -├── duration_ms -├── status // "success", "failed" -├── result_count -└── children // Sub-operations - ├── embedding (kb only) - ├── vector_search (kb only) - ├── graph_search (kb, if enabled) - ├── querydsl_build (db only) - ├── db_query (db only) - └── rerank (if enabled) +├── label // i18n: "Search" / "搜索" +├── status // "pending" | "running" | "completed" | "failed" +├── input +│ ├── query // Original query +│ └── types // ["web"], ["kb"], ["web", "kb", "db"] +└── output // (set on complete) + └── result_count // Total results found +``` + +**Parallel Search:** + +``` +search (type: "search") +├── label // i18n: "Search" / "搜索" +├── status // "pending" | "running" | "completed" | "failed" +├── input +│ ├── query // Original query +│ └── types // ["web", "kb", "db"] +└── children + ├── web (type: "search_item") + │ ├── label // i18n: "Web Search" / "网页搜索" + │ ├── status // "pending" | "running" | "completed" | "failed" + │ └── output + │ └── result_count + ├── kb (type: "search_item") + │ └── ... + └── db (type: "search_item") + └── ... +``` + +### Trace Logging + +Detailed search information is recorded via Trace node logging methods (broadcasts to client): + +```go +// Node logging methods (from trace/node.go): +// - node.Info(message, args...) - Info level log +// - node.Debug(message, args...) - Debug level log +// - node.Warn(message, args...) - Warning level log +// - node.Error(message, args...) - Error level log + +// Search start +searchNode.Info("Starting search", map[string]any{"query": query, "types": types}) + +// Per-type results (on parallel search children) +webNode.Debug("Web search completed", map[string]any{"count": count, "duration_ms": duration}) +kbNode.Debug("KB search completed", map[string]any{"count": count, "duration_ms": duration}) +dbNode.Debug("DB search completed", map[string]any{"count": count, "duration_ms": duration}) + +// Errors (non-blocking, search continues) +webNode.Warn("Web search failed", map[string]any{"error": err.Error()}) + +// Final summary (on parent node) +searchNode.Info("Search completed", map[string]any{"total": total, "duration_ms": duration}) +``` + +**Log Event Structure** (broadcasted via SSE): + +```go +// types.TraceLog +type TraceLog struct { + Timestamp int64 `json:"timestamp"` // milliseconds since epoch + Level string `json:"level"` // "info", "debug", "warn", "error" + Message string `json:"message"` // Log message + Data any `json:"data"` // Additional data + NodeID string `json:"node_id"` // Parent node ID +} ``` ## Real-time Output -Search progress is streamed to the client via the output system. +Search progress is displayed to the client using **Loading component with Replace** pattern. Uses `ctx.Send()` and `ctx.Replace()` methods. -### Output Message Types +### Output Flow + +``` +1. Send Loading Message + loading_id = ctx.Send({ type: "loading", props: { message: "Searching..." } }) + → Client displays loading indicator + +2. Execute Search (parallel web/kb/db) + +3. Replace with Result Message (shows result to user) + ctx.Replace(loading_id, { type: "loading", props: { message: "Found 5 references" } }) + → Client displays result message + +4. Mark as Done (removes the loading after brief display) + ctx.Replace(loading_id, { type: "loading", props: { message: "Found 5 references", done: true } }) + → Client removes loading indicator +``` + +### Implementation ```go -const ( - TypeSearchStart = "search_start" // Search initiated - TypeSearchResult = "search_result" // Result item (streamed) - TypeSearchComplete = "search_complete" // Search completed -) +// Send loading message +loadingID := ctx.Send(map[string]any{ + "type": "loading", + "props": map[string]any{ + "message": i18n.Tr("search.loading", locale), // "Searching..." / "正在搜索..." + }, +}) + +// Execute search... + +// Replace with result message (displayed to user) +resultMessage := i18n.Tr("search.success", locale, count) // "Found 5 references" +ctx.Replace(loadingID, map[string]any{ + "type": "loading", + "props": map[string]any{ + "message": resultMessage, + }, +}) + +// Mark as done (removes loading indicator after user sees the result) +ctx.Replace(loadingID, map[string]any{ + "type": "loading", + "props": map[string]any{ + "message": resultMessage, + "done": true, // Frontend will remove loading indicator + }, +}) ``` +### Loading Props + +| Prop | Type | Description | +| --------- | ------ | --------------------------------------------------- | +| `message` | string | Localized message to display | +| `done` | bool | When `true`, frontend removes the loading indicator | + +### Localized Messages + +| Scenario | English | Chinese | +| ------------- | ---------------------------------------- | --------------------------------- | +| Loading | Searching... | 正在搜索... | +| Success (1) | Found 1 reference | 找到 1 条参考资料 | +| Success (N) | Found N references | 找到 N 条参考资料 | +| Partial Error | Found N references (some sources failed) | 找到 N 条参考资料(部分来源失败) | +| All Failed | Search failed | 搜索失败 | +| No Results | No references found | 未找到相关资料 | + ### Client Display Example ``` -🔍 Searching "latest AI developments"... +Frame 1 - During search: +┌─────────────────────────────────┐ +│ Searching... │ ← Loading (done: false) +└─────────────────────────────────┘ -📄 Found 5 results: - 1. #ref:a1b2 - OpenAI Announces GPT-5 - 2. #ref:c3d4 - Google's New AI Model - ... +Frame 2 - Result displayed: +┌─────────────────────────────────┐ +│ Found 5 references │ ← Result (done: false) +└─────────────────────────────────┘ -✅ Search complete (1.2s) +Frame 3 - Removed: +(loading indicator removed when done: true) ``` ## JSAPI Integration @@ -1978,7 +2103,7 @@ SerpAPI supports multiple search engines via the `engine` config: | ------------ | ---------------------------- | | `google` | Google Search (default) | | `bing` | Bing Search | -| `baidu` | Baidu (百度) | +| `baidu` | Baidu Search (Chinese) | | `yandex` | Yandex Search | | `yahoo` | Yahoo Search | | `duckduckgo` | DuckDuckGo Search |