From a2814e625dd0e5acd7dac7656bf41a547d9d979e Mon Sep 17 00:00:00 2001 From: shentongmartin Date: Mon, 16 Mar 2026 18:23:40 +0800 Subject: [PATCH] feat(chatwitheino): add filesystem-backed skills (#185) --- .gitignore | 1 + quickstart/chatwitheino/agent.go | 42 +- quickstart/chatwitheino/cmd/ch09/main.go | 588 ++++++++++++++ .../docs/ch01_chatmodel_agent_console.md | 3 +- quickstart/chatwitheino/docs/ch09_a2ui.md | 334 -------- quickstart/chatwitheino/docs/ch09_skill.md | 137 ++++ quickstart/chatwitheino/docs/ch10_a2ui.md | 242 ++++++ .../docs/documentation_review_report.md | 752 ------------------ .../scripts/sync_eino_ext_skills.go | 255 ++++++ 9 files changed, 1263 insertions(+), 1091 deletions(-) create mode 100644 quickstart/chatwitheino/cmd/ch09/main.go delete mode 100644 quickstart/chatwitheino/docs/ch09_a2ui.md create mode 100644 quickstart/chatwitheino/docs/ch09_skill.md create mode 100644 quickstart/chatwitheino/docs/ch10_a2ui.md delete mode 100644 quickstart/chatwitheino/docs/documentation_review_report.md create mode 100644 quickstart/chatwitheino/scripts/sync_eino_ext_skills.go diff --git a/.gitignore b/.gitignore index 3c805a7..0b4a97e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ log/ CLAUDE.md quickstart/chatwithdoc/data/sessions/ quickstart/chatwithdoc/data/workspace/ +quickstart/chatwitheino/skills/ *.jsonl *.txt diff --git a/quickstart/chatwitheino/agent.go b/quickstart/chatwitheino/agent.go index 6085ef0..4a3c023 100644 --- a/quickstart/chatwitheino/agent.go +++ b/quickstart/chatwitheino/agent.go @@ -21,10 +21,13 @@ import ( "errors" "fmt" "io" + "os" + "path/filepath" "strings" localbk "github.com/cloudwego/eino-ext/adk/backend/local" "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/adk/middlewares/skill" "github.com/cloudwego/eino/adk/prebuilt/deep" "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/compose" @@ -48,6 +51,25 @@ func buildAgent(ctx context.Context) (adk.Agent, error) { return nil, fmt.Errorf("build rag tool: %w", err) } + var handlers []adk.ChatModelAgentMiddleware + if skillsDir, ok := resolveSkillsDir(); ok { + skillBackend, sbErr := skill.NewBackendFromFilesystem(ctx, &skill.BackendFromFilesystemConfig{ + Backend: backend, + BaseDir: skillsDir, + }) + if sbErr != nil { + return nil, sbErr + } + skillMiddleware, smErr := skill.NewMiddleware(ctx, &skill.Config{ + Backend: skillBackend, + }) + if smErr != nil { + return nil, smErr + } + handlers = append(handlers, skillMiddleware) + } + handlers = append(handlers, &approvalMiddleware{}, &safeToolMiddleware{}) + return deep.New(ctx, &deep.Config{ Name: "ChatWithDocAgent", Description: "An agent that reads and answers questions about documents.", @@ -55,10 +77,7 @@ func buildAgent(ctx context.Context) (adk.Agent, error) { Backend: backend, StreamingShell: backend, MaxIteration: 50, - Handlers: []adk.ChatModelAgentMiddleware{ - &approvalMiddleware{}, - &safeToolMiddleware{}, - }, + Handlers: handlers, ToolsConfig: adk.ToolsConfig{ ToolsNodeConfig: compose.ToolsNodeConfig{ Tools: []tool.BaseTool{ragTool}, @@ -75,6 +94,21 @@ func buildAgent(ctx context.Context) (adk.Agent, error) { }) } +func resolveSkillsDir() (string, bool) { + skillsDir := strings.TrimSpace(os.Getenv("EINO_EXT_SKILLS_DIR")) + if skillsDir == "" { + return "", false + } + if absSkillsDir, absErr := filepath.Abs(skillsDir); absErr == nil { + skillsDir = absSkillsDir + } + fi, err := os.Stat(skillsDir) + if err != nil || !fi.IsDir() { + return "", false + } + return skillsDir, true +} + // safeToolMiddleware converts streaming tool errors into error-message strings // so that a non-zero exit code or mid-stream failure is returned to the model // as a readable tool result instead of aborting the agent pipeline. diff --git a/quickstart/chatwitheino/cmd/ch09/main.go b/quickstart/chatwitheino/cmd/ch09/main.go new file mode 100644 index 0000000..1982ed1 --- /dev/null +++ b/quickstart/chatwitheino/cmd/ch09/main.go @@ -0,0 +1,588 @@ +/* + * Copyright 2026 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + + localbk "github.com/cloudwego/eino-ext/adk/backend/local" + clc "github.com/cloudwego/eino-ext/callbacks/cozeloop" + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/adk/middlewares/skill" + "github.com/cloudwego/eino/adk/prebuilt/deep" + "github.com/cloudwego/eino/callbacks" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" + "github.com/coze-dev/cozeloop-go" + + examplemodel "github.com/cloudwego/eino-examples/adk/common/model" + adkstore "github.com/cloudwego/eino-examples/adk/common/store" + commontool "github.com/cloudwego/eino-examples/adk/common/tool" + "github.com/cloudwego/eino-examples/quickstart/chatwitheino/mem" + "github.com/cloudwego/eino-examples/quickstart/chatwitheino/rag" +) + +func main() { + var sessionID string + var instruction string + flag.StringVar(&sessionID, "session", "", "session ID (creates new if empty)") + flag.StringVar(&instruction, "instruction", "", "custom instruction (empty for default)") + flag.Parse() + + ctx := context.Background() + + cozeloopApiToken := os.Getenv("COZELOOP_API_TOKEN") + cozeloopWorkspaceID := os.Getenv("COZELOOP_WORKSPACE_ID") + if cozeloopApiToken != "" && cozeloopWorkspaceID != "" { + client, err := cozeloop.NewClient( + cozeloop.WithAPIToken(cozeloopApiToken), + cozeloop.WithWorkspaceID(cozeloopWorkspaceID), + ) + if err != nil { + log.Fatalf("cozeloop.NewClient failed: %v", err) + } + defer func() { + time.Sleep(5 * time.Second) + client.Close(ctx) + }() + callbacks.AppendGlobalHandlers(clc.NewLoopHandler(client)) + log.Println("CozeLoop tracing enabled") + } else { + log.Println("CozeLoop tracing disabled (set COZELOOP_API_TOKEN and COZELOOP_WORKSPACE_ID to enable)") + } + + cm := examplemodel.NewChatModel() + + projectRoot := os.Getenv("PROJECT_ROOT") + if projectRoot == "" { + if cwd, err := os.Getwd(); err == nil { + projectRoot = cwd + } + } + if abs, err := filepath.Abs(projectRoot); err == nil { + projectRoot = abs + } + + defaultInstruction := fmt.Sprintf(`You are a helpful assistant that helps users learn the Eino framework. + +IMPORTANT: When using filesystem tools (ls, read_file, glob, grep, etc.), you MUST use absolute paths. + +The project root directory is: %s + +- When the user asks to list files in "current directory", use path: %s +- When the user asks to read a file with a relative path, convert it to absolute path by prepending %s +- Example: if user says "read main.go", you should call read_file with file_path: "%s/main.go" + +Always use absolute paths when calling filesystem tools.`, projectRoot, projectRoot, projectRoot, projectRoot) + + agentInstruction := defaultInstruction + if instruction != "" { + agentInstruction = instruction + } + + backend, err := localbk.NewBackend(ctx, &localbk.Config{}) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + ragTool, err := rag.BuildTool(ctx, cm) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, fmt.Errorf("build rag tool: %w", err)) + os.Exit(1) + } + + var handlers []adk.ChatModelAgentMiddleware + skillsDir, found := resolveSkillsDir() + if found { + skillBackend, sbErr := skill.NewBackendFromFilesystem(ctx, &skill.BackendFromFilesystemConfig{ + Backend: backend, + BaseDir: skillsDir, + }) + if sbErr != nil { + _, _ = fmt.Fprintln(os.Stderr, sbErr) + os.Exit(1) + } + skillMiddleware, smErr := skill.NewMiddleware(ctx, &skill.Config{ + Backend: skillBackend, + }) + if smErr != nil { + _, _ = fmt.Fprintln(os.Stderr, smErr) + os.Exit(1) + } + handlers = append(handlers, skillMiddleware) + } + handlers = append(handlers, &approvalMiddleware{}, &safeToolMiddleware{}) + + agent, err := deep.New(ctx, &deep.Config{ + Name: "Ch09RAGSkillAgent", + Description: "ChatWithDoc agent with RAG tool and skill middleware.", + ChatModel: cm, + Instruction: agentInstruction, + Backend: backend, + StreamingShell: backend, + MaxIteration: 50, + Handlers: handlers, + ToolsConfig: adk.ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{ragTool}, + }, + }, + ModelRetryConfig: &adk.ModelRetryConfig{ + MaxRetries: 5, + IsRetryAble: func(_ context.Context, err error) bool { + return strings.Contains(err.Error(), "429") || + strings.Contains(err.Error(), "Too Many Requests") || + strings.Contains(err.Error(), "qpm limit") + }, + }, + }) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, + CheckPointStore: adkstore.NewInMemoryStore(), + }) + + sessionDir := os.Getenv("SESSION_DIR") + if sessionDir == "" { + sessionDir = "./data/sessions" + } + + store, err := mem.NewStore(sessionDir) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if sessionID == "" { + sessionID = uuid.New().String() + fmt.Printf("Created new session: %s\n", sessionID) + } else { + fmt.Printf("Resuming session: %s\n", sessionID) + } + + session, err := store.GetOrCreate(sessionID) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Printf("Session title: %s\n", session.Title()) + fmt.Printf("Project root: %s\n", projectRoot) + if found { + fmt.Printf("Skills dir: %s\n", skillsDir) + } else { + fmt.Println("Skills dir: (not configured) set EINO_EXT_SKILLS_DIR=/path/to/skills") + } + fmt.Println("Enter your message (empty line to exit):") + + reader := bufio.NewReader(os.Stdin) + checkPointID := sessionID + for { + _, _ = fmt.Fprint(os.Stdout, "you> ") + line, readErr := reader.ReadString('\n') + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + _, _ = fmt.Fprintln(os.Stderr, readErr) + os.Exit(1) + } + line = strings.TrimSpace(line) + if line == "" { + break + } + + userMsg := schema.UserMessage(line) + if err := session.Append(userMsg); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + history := session.GetMessages() + events := runner.Run(ctx, history, adk.WithCheckPointID(checkPointID)) + content, interruptInfo, err := printAndCollectAssistantFromEvents(events) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if interruptInfo != nil { + content, err = handleInterrupt(ctx, runner, checkPointID, interruptInfo, reader) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + + assistantMsg := schema.AssistantMessage(content, nil) + if err := session.Append(assistantMsg); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + + fmt.Printf("\nSession saved: %s\n", sessionID) + fmt.Printf("Resume with: go run ./cmd/ch09 --session %s\n", sessionID) +} + +func resolveSkillsDir() (string, bool) { + skillsDir := strings.TrimSpace(os.Getenv("EINO_EXT_SKILLS_DIR")) + if skillsDir == "" { + return "", false + } + if absSkillsDir, absErr := filepath.Abs(skillsDir); absErr == nil { + skillsDir = absSkillsDir + } + fi, err := os.Stat(skillsDir) + if err != nil || !fi.IsDir() { + return "", false + } + return skillsDir, true +} + +type approvalMiddleware struct { + *adk.BaseChatModelAgentMiddleware +} + +func (m *approvalMiddleware) WrapInvokableToolCall( + _ context.Context, + endpoint adk.InvokableToolCallEndpoint, + tCtx *adk.ToolContext, +) (adk.InvokableToolCallEndpoint, error) { + if tCtx.Name != "answer_from_document" { + return endpoint, nil + } + return func(ctx context.Context, args string, opts ...tool.Option) (string, error) { + wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx) + if !wasInterrupted { + return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{ + ToolName: tCtx.Name, + ArgumentsInJSON: args, + }, args) + } + + isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx) + if isTarget && hasData { + if data.Approved { + return endpoint(ctx, storedArgs, opts...) + } + if data.DisapproveReason != nil { + return fmt.Sprintf("tool '%s' disapproved: %s", tCtx.Name, *data.DisapproveReason), nil + } + return fmt.Sprintf("tool '%s' disapproved", tCtx.Name), nil + } + + isTarget2, _, _ := tool.GetResumeContext[any](ctx) + if !isTarget2 { + return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{ + ToolName: tCtx.Name, + ArgumentsInJSON: storedArgs, + }, storedArgs) + } + + return endpoint(ctx, storedArgs, opts...) + }, nil +} + +func (m *approvalMiddleware) WrapStreamableToolCall( + _ context.Context, + endpoint adk.StreamableToolCallEndpoint, + tCtx *adk.ToolContext, +) (adk.StreamableToolCallEndpoint, error) { + if tCtx.Name != "answer_from_document" { + return endpoint, nil + } + return func(ctx context.Context, args string, opts ...tool.Option) (*schema.StreamReader[string], error) { + wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx) + if !wasInterrupted { + return nil, tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{ + ToolName: tCtx.Name, + ArgumentsInJSON: args, + }, args) + } + + isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx) + if isTarget && hasData { + if data.Approved { + return endpoint(ctx, storedArgs, opts...) + } + if data.DisapproveReason != nil { + return singleChunkReader(fmt.Sprintf("tool '%s' disapproved: %s", tCtx.Name, *data.DisapproveReason)), nil + } + return singleChunkReader(fmt.Sprintf("tool '%s' disapproved", tCtx.Name)), nil + } + + isTarget2, _, _ := tool.GetResumeContext[any](ctx) + if !isTarget2 { + return nil, tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{ + ToolName: tCtx.Name, + ArgumentsInJSON: storedArgs, + }, storedArgs) + } + + return endpoint(ctx, storedArgs, opts...) + }, nil +} + +type safeToolMiddleware struct { + *adk.BaseChatModelAgentMiddleware +} + +func (m *safeToolMiddleware) WrapInvokableToolCall( + _ context.Context, + endpoint adk.InvokableToolCallEndpoint, + _ *adk.ToolContext, +) (adk.InvokableToolCallEndpoint, error) { + return func(ctx context.Context, args string, opts ...tool.Option) (string, error) { + result, err := endpoint(ctx, args, opts...) + if err != nil { + if _, ok := compose.IsInterruptRerunError(err); ok { + return "", err + } + return fmt.Sprintf("[tool error] %v", err), nil + } + return result, nil + }, nil +} + +func (m *safeToolMiddleware) WrapStreamableToolCall( + _ context.Context, + endpoint adk.StreamableToolCallEndpoint, + _ *adk.ToolContext, +) (adk.StreamableToolCallEndpoint, error) { + return func(ctx context.Context, args string, opts ...tool.Option) (*schema.StreamReader[string], error) { + sr, err := endpoint(ctx, args, opts...) + if err != nil { + if _, ok := compose.IsInterruptRerunError(err); ok { + return nil, err + } + return singleChunkReader(fmt.Sprintf("[tool error] %v", err)), nil + } + return safeWrapReader(sr), nil + }, nil +} + +func singleChunkReader(msg string) *schema.StreamReader[string] { + r, w := schema.Pipe[string](1) + _ = w.Send(msg, nil) + w.Close() + return r +} + +func safeWrapReader(sr *schema.StreamReader[string]) *schema.StreamReader[string] { + r, w := schema.Pipe[string](64) + go func() { + defer w.Close() + for { + chunk, err := sr.Recv() + if errors.Is(err, io.EOF) { + return + } + if err != nil { + _ = w.Send(fmt.Sprintf("\n[tool error] %v", err), nil) + return + } + _ = w.Send(chunk, nil) + } + }() + return r +} + +func printAndCollectAssistantFromEvents(events *adk.AsyncIterator[*adk.AgentEvent]) (string, *adk.InterruptInfo, error) { + var sb strings.Builder + var interruptInfo *adk.InterruptInfo + + for { + event, ok := events.Next() + if !ok { + break + } + if event.Err != nil { + return "", nil, event.Err + } + + if event.Action != nil && event.Action.Interrupted != nil { + interruptInfo = event.Action.Interrupted + continue + } + + if event.Output != nil && event.Output.MessageOutput != nil { + mv := event.Output.MessageOutput + if mv.Role == schema.Tool { + content := drainToolResult(mv) + fmt.Printf("[tool result] %s\n", truncate(content, 200)) + continue + } + + if mv.Role != schema.Assistant && mv.Role != "" { + continue + } + + if mv.IsStreaming { + mv.MessageStream.SetAutomaticClose() + var accumulatedToolCalls []schema.ToolCall + for { + frame, err := mv.MessageStream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", nil, err + } + if frame != nil { + if frame.Content != "" { + sb.WriteString(frame.Content) + _, _ = fmt.Fprint(os.Stdout, frame.Content) + } + if len(frame.ToolCalls) > 0 { + accumulatedToolCalls = append(accumulatedToolCalls, frame.ToolCalls...) + } + } + } + for _, tc := range accumulatedToolCalls { + if tc.Function.Name != "" && tc.Function.Arguments != "" { + fmt.Printf("\n[tool call] %s(%s)\n", tc.Function.Name, tc.Function.Arguments) + } + } + _, _ = fmt.Fprintln(os.Stdout) + continue + } + + if mv.Message != nil { + sb.WriteString(mv.Message.Content) + _, _ = fmt.Fprintln(os.Stdout, mv.Message.Content) + for _, tc := range mv.Message.ToolCalls { + fmt.Printf("[tool call] %s(%s)\n", tc.Function.Name, tc.Function.Arguments) + } + } + } + } + + return sb.String(), interruptInfo, nil +} + +func drainToolResult(mo *adk.MessageVariant) string { + if mo.IsStreaming && mo.MessageStream != nil { + var sb strings.Builder + for { + chunk, err := mo.MessageStream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + break + } + if chunk != nil && chunk.Content != "" { + sb.WriteString(chunk.Content) + } + } + return sb.String() + } + if mo.Message != nil { + return mo.Message.Content + } + return "" +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + var result bytes.Buffer + if err := json.Compact(&result, []byte(s)); err == nil { + s = result.String() + } + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +func handleInterrupt(ctx context.Context, runner *adk.Runner, checkPointID string, interruptInfo *adk.InterruptInfo, reader *bufio.Reader) (string, error) { + for _, ic := range interruptInfo.InterruptContexts { + if !ic.IsRootCause { + continue + } + + info, ok := ic.Info.(*commontool.ApprovalInfo) + if !ok { + continue + } + + fmt.Printf("\n⚠️ Approval Required ⚠️\n") + fmt.Printf("Tool: %s\n", info.ToolName) + fmt.Printf("Arguments: %s\n", info.ArgumentsInJSON) + fmt.Print("\nApprove this action? (y/n): ") + + response, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read user input: %w", err) + } + response = strings.TrimSpace(strings.ToLower(response)) + + var resumeData *commontool.ApprovalResult + if response == "y" || response == "yes" { + resumeData = &commontool.ApprovalResult{Approved: true} + fmt.Println("✓ Approved, executing...") + } else { + resumeData = &commontool.ApprovalResult{Approved: false} + fmt.Println("✗ Rejected") + } + + events, err := runner.ResumeWithParams(ctx, checkPointID, &adk.ResumeParams{ + Targets: map[string]any{ + ic.ID: resumeData, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to resume: %w", err) + } + + content, newInterruptInfo, err := printAndCollectAssistantFromEvents(events) + if err != nil { + return "", err + } + + if newInterruptInfo != nil { + return handleInterrupt(ctx, runner, checkPointID, newInterruptInfo, reader) + } + + return content, nil + } + return "", fmt.Errorf("no root cause interrupt context found") +} diff --git a/quickstart/chatwitheino/docs/ch01_chatmodel_agent_console.md b/quickstart/chatwitheino/docs/ch01_chatmodel_agent_console.md index 98320f9..5313811 100644 --- a/quickstart/chatwitheino/docs/ch01_chatmodel_agent_console.md +++ b/quickstart/chatwitheino/docs/ch01_chatmodel_agent_console.md @@ -56,7 +56,8 @@ ChatWithEino 是一个基于 Eino 框架构建的智能助手,能够帮助开 | **第六章** | Callback | 回调机制,监控 Agent 执行过程 | 可观测性 | | **第七章** | Interrupt 与 Resume | 中断与恢复,支持长时间任务 | 可靠性增强 | | **第八章** | Graph 与 Tool | 使用 Graph 编排复杂工作流 | 复杂编排能力 | -| **第九章** | A2UI | Agent 到 UI 的集成方案 | 生产级应用 | +| **第九章** | Skill | 使用 Skill 中间件加载并复用技能文档 | 知识复用能力 | +| **最终章** | A2UI | Agent 到 UI 的集成方案 | 生产级应用 | **为什么这样设计?** diff --git a/quickstart/chatwitheino/docs/ch09_a2ui.md b/quickstart/chatwitheino/docs/ch09_a2ui.md deleted file mode 100644 index 9ef8902..0000000 --- a/quickstart/chatwitheino/docs/ch09_a2ui.md +++ /dev/null @@ -1,334 +0,0 @@ ---- -title: "第九章:A2UI 协议(流式 UI 组件)" ---- - -本章目标:实现 A2UI 协议,将 Agent 的输出渲染为流式 UI 组件。 - -## 重要说明:A2UI 的边界 - -A2UI 并不属于 Eino 框架本身的范畴,它是一个业务层的 UI 协议/渲染方案。本章把 A2UI 集成进前面章节逐步构建出来的 Agent,是为了提供一个端到端、可落地的完整示例:从模型调用、工具调用、工作流编排,到最终把结果以更友好的 UI 方式呈现出来。 - -在真实业务场景中,你完全可以根据产品形态选择不同的 UI 形式,例如: - -- Web / App:自定义组件、表格、卡片、图表等 -- IM/办公套件:消息卡片、交互式表单 -- 命令行:纯文本或 TUI(终端 UI) - -Eino 更关注“可组合的智能执行与编排能力”,至于“如何呈现给用户”,属于业务层可以自由扩展的一环。 - -## 代码位置 - -- 入口代码:[main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/main.go) -- A2UI 实装:[a2ui/streamer.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/streamer.go) - -## 前置条件 - -与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark) - -## 运行 - -在 `examples/quickstart/chatwitheino` 目录下执行: - -```bash -go run . -``` - -输出示例: - -```text -starting server on http://localhost:8080 -``` - -## 从文本到 UI:为什么需要 A2UI - -前八章我们实现的 Agent 只输出文本,但现代 AI 应用需要更丰富的交互。 - -**纯文本输出的局限:** -- 无法展示结构化数据(表格、列表、卡片等) -- 无法实时更新(进度条、状态变化等) -- 无法嵌入交互元素(按钮、表单、链接等) -- 无法支持多媒体(图片、视频、音频等) - -**A2UI 的定位:** -- **A2UI 是 Agent 到 UI 的协议**:定义了 Agent 输出如何映射到 UI 组件 -- **A2UI 支持流式渲染**:组件可以实时更新,无需等待完整响应 -- **A2UI 是声明式的**:Agent 只需声明"显示什么",UI 负责渲染 - -**简单类比:** -- **纯文本输出** = "终端命令行"(只能显示文本) -- **A2UI** = "Web 应用"(可以显示任何 UI 组件) - -## 关键概念 - -### A2UI 组件 - -A2UI 定义了一系列 UI 组件类型: - -```go -type ComponentType string - -const ( - ComponentText ComponentType = "text" // 文本 - ComponentMarkdown ComponentType = "markdown" // Markdown - ComponentCode ComponentType = "code" // 代码块 - ComponentImage ComponentType = "image" // 图片 - ComponentTable ComponentType = "table" // 表格 - ComponentCard ComponentType = "card" // 卡片 - ComponentButton ComponentType = "button" // 按钮 - ComponentForm ComponentType = "form" // 表单 - ComponentProgress ComponentType = "progress" // 进度条 - ComponentDivider ComponentType = "divider" // 分隔线 -) -``` - -### A2UI 消息 - -每条 A2UI 消息包含: - -```go -type Message struct { - ID string // 消息 ID - Role string // user / assistant - Components []Component // UI 组件列表 - Timestamp time.Time // 时间戳 -} -``` - -### A2UI 流式输出 - -A2UI 支持流式输出组件: - -```go -type StreamMessage struct { - Type string // add / update / delete - Index int // 组件索引 - Component Component // 组件内容 -} -``` - -**流式更新类型:** -- `add`:添加新组件 -- `update`:更新已有组件 -- `delete`:删除组件 - -## A2UI 的实现 - -### 1. 创建 A2UI Streamer - -```go -streamer := a2ui.NewStreamer() -``` - -### 2. 添加组件 - -```go -// 添加文本组件 -streamer.AddText("正在处理您的请求...") - -// 添加进度条 -streamer.AddProgress(0, 100, "加载中") - -// 更新进度 -streamer.UpdateProgress(0, 50, "处理中") - -// 添加代码块 -streamer.AddCode("go", `fmt.Println("Hello, World!")`) - -// 添加表格 -streamer.AddTable([][]string{ - {"Name", "Age", "City"}, - {"Alice", "30", "New York"}, - {"Bob", "25", "London"}, -}) -``` - -### 3. 流式输出 - -```go -// 获取流式消息 -stream := streamer.Stream() - -for { - msg, ok := stream.Next() - if !ok { - break - } - // 发送到前端 - sendToClient(msg) -} -``` - -**关键代码片段(**注意:这是简化后的代码片段,不能直接运行,完整代码请参考** [cmd/ch09/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch09/main.go)): - -```go -// 创建 A2UI Streamer -streamer := a2ui.NewStreamer() - -// Agent 执行过程中添加组件 -streamer.AddText("我来帮你分析这个文件...") - -// 调用 Tool -streamer.AddProgress(0, 0, "读取文件") -result, err := tool.Run(ctx, args) -streamer.UpdateProgress(0, 100, "完成") - -// 显示结果 -streamer.AddCode("json", result) - -// 流式输出 -stream := streamer.Stream() -for { - msg, ok := stream.Next() - if !ok { - break - } - wsConn.WriteJSON(msg) -} -``` - -## A2UI 与 Agent 的集成 - -### 在 Agent 中使用 A2UI - -```go -func buildAgent(ctx context.Context) (adk.Agent, error) { - return deep.New(ctx, &deep.Config{ - Name: "A2UIAgent", - Description: "Agent with A2UI streaming output", - ChatModel: cm, - Backend: backend, - // 配置 A2UI Streamer - StreamingShell: backend, - }) -} -``` - -### 在 Runner 中使用 A2UI - -```go -runner := adk.NewRunner(ctx, adk.RunnerConfig{ - Agent: agent, - EnableStreaming: true, -}) - -// 执行 Agent -events := runner.Run(ctx, history) - -// 将事件转换为 A2UI 组件 -streamer := a2ui.NewStreamer() -for { - event, ok := events.Next() - if !ok { - break - } - if event.Output != nil && event.Output.MessageOutput != nil { - // 添加文本组件 - streamer.AddText(event.Output.MessageOutput.Message.Content) - } -} -``` - -## A2UI 流式渲染流程 - -``` -┌─────────────────────────────────────────┐ -│ 用户:分析这个文件 │ -└─────────────────────────────────────────┘ - ↓ - ┌──────────────────────┐ - │ Agent 开始处理 │ - │ A2UI: AddText │ - │ "正在分析..." │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 调用 Tool │ - │ A2UI: AddProgress │ - │ 进度: 0% │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ Tool 执行中 │ - │ A2UI: UpdateProgress│ - │ 进度: 50% │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ Tool 完成 │ - │ A2UI: UpdateProgress│ - │ 进度: 100% │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 显示结果 │ - │ A2UI: AddCode │ - │ 代码块 │ - └──────────────────────┘ -``` - -## 前端集成 - -### WebSocket 连接 - -```javascript -const ws = new WebSocket('ws://localhost:8080/ws'); - -ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - renderComponent(msg); -}; - -function renderComponent(msg) { - const { type, index, component } = msg; - - switch (component.type) { - case 'text': - renderText(component.content); - break; - case 'code': - renderCode(component.language, component.content); - break; - case 'progress': - renderProgress(component.value, component.max, component.label); - break; - // ... - } -} -``` - -## 本章小结 - -- **A2UI**:Agent 到 UI 的协议,定义了 Agent 输出如何映射到 UI 组件 -- **组件类型**:文本、Markdown、代码、图片、表格、卡片、按钮、表单、进度条等 -- **流式输出**:支持实时添加、更新、删除组件 -- **声明式**:Agent 只需声明"显示什么",UI 负责渲染 -- **前端集成**:通过 WebSocket 实现实时通信 - -## 系列收尾:这个 Quickstart Agent 的完整愿景 - -到本章为止,我们用一个可以实际运行的 Agent 串起了 Eino 的核心能力。你可以把它理解为一个可扩展的“端到端 Agent 应用骨架”: - -- 运行时:Runner 驱动执行,支持流式输出与事件模型 -- 工具层:Filesystem / Shell 等 Tool 能力接入,工具错误可被安全处理 -- 中间件:可插拔的 middleware/handler,用于错误处理、重试、审批等横切能力 -- 可观测:callbacks/trace 能力把关键链路打通,便于调试与线上观测 -- 人机协作:interrupt/resume + checkpoint 支持审批、补参、分支选择等交互式流程 -- 确定性编排:compose(graph/chain/workflow)把复杂业务流程组织为可维护、可复用的执行图 -- 业务交付:像 A2UI 这样的 UI 集成,属于业务层自由选择的一环,用来把 Agent 能力以合适的产品形态呈现给用户 - -你可以在这个骨架上逐步替换/扩展任意环节:模型、工具、存储、工作流、前端渲染协议,而不需要推倒重来。 - -## 扩展思考 - -**其他组件类型:** -- 图表组件(折线图、柱状图、饼图) -- 地图组件 -- 时间线组件 -- 树形组件 -- 标签页组件 - -**高级功能:** -- 组件交互(点击、拖拽、输入) -- 条件渲染 -- 组件动画 -- 响应式布局 diff --git a/quickstart/chatwitheino/docs/ch09_skill.md b/quickstart/chatwitheino/docs/ch09_skill.md new file mode 100644 index 0000000..26281a7 --- /dev/null +++ b/quickstart/chatwitheino/docs/ch09_skill.md @@ -0,0 +1,137 @@ +--- +title: "第九章:Skill(Console)" +--- + +本章目标:在第八章(RAG + Interrupt/Resume + Checkpoint)基础上,引入 `skill` 中间件,让 Agent 可以发现并加载一组可复用的技能文档(`SKILL.md`),并在需要时通过工具调用使用它们。 + +## 代码位置 + +- 入口代码:[cmd/ch09/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch09/main.go) +- 同步脚本:[scripts/sync_eino_ext_skills.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/scripts/sync_eino_ext_skills.go) + +## 前置条件 + +- 与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark) +- 准备好 `eino-ext` PR 提供的 skills(`eino-guide` / `eino-component` / `eino-compose` / `eino-agent`) + +为什么是这四个? + +ChatWithEino 的定位是“帮用户学习 Eino 框架、并尝试用 AI 辅助写 Eino 代码”。这四个 skills 正好覆盖了这个目标所需的关键知识面: + +- `eino-guide`:学习入口与导航(从哪里开始、怎么快速跑起来) +- `eino-component`:Component 接口与各类实现参考(Model/Embedding/Retriever/Tool/Callback 等) +- `eino-compose`:编排与确定性工作流参考(Graph/Chain/Workflow 等) +- `eino-agent`:ADK/Agent 相关参考(Agent、Runner、Middleware、Filesystem、Human-in-the-loop 等) + +skills 的来源可以是: + +- `eino-ext` 仓库本地路径(脚本会自动读取 `/skills/...`) +- 或你已安装 skills 的目录(目录下能看到上述四个子目录) + +## 从 Graph Tool 到 Skill:为什么需要“技能文档” + +第八章解决的是“复杂工作流如何做成一个可调用的 Tool”(Graph Tool)。但你在构建一个面向框架学习/开发辅助的 Agent 时,还会遇到另一类问题:**如何把一组稳定、可复用的知识与指令注入到 Agent 里,并让它在运行时按需加载?** + +这就是 Skill 的定位: + +- **Tool** 更像“动作/能力”:读文件、跑 workflow、调用外部系统 +- **Skill** 更像“可复用的知识/指令包”:用一组 markdown(`SKILL.md` + `reference/*.md`)描述“如何做某类事” + +简单类比: + +- **Tool** = “能做什么”(函数/接口) +- **Skill** = “怎么做”(可复用的说明书/操作手册) + +## 运行 + +在 `quickstart/chatwitheino` 目录下执行: + +### 1) 同步 eino-ext skills 到本地目录 + +为了让 `skill` 中间件可以“发现”这些 skills,需要把它们放到一个统一目录下,并满足扫描约定: + +- `EINO_EXT_SKILLS_DIR//SKILL.md` + +同步命令(推荐): + +```bash +go run ./scripts/sync_eino_ext_skills.go -src /path/to/eino-ext -dest ./skills/eino-ext -clean +``` + +说明: + +- `-src` 支持两种形式: + - `eino-ext` 仓库根目录(脚本会自动读取 `/skills/...`) + - 你已安装 skills 的目录(目录下应包含 `eino-guide/`、`eino-component/` 等子目录) +- `-dest` 默认是 `./skills/eino-ext`(可以省略) + +### 2) 启动 Chapter 9 + +```bash +EINO_EXT_SKILLS_DIR=/absolute/path/to/chatwitheino/skills/eino-ext go run ./cmd/ch09 +``` + +输出示例(节选): + +```text +Skills dir: /.../skills/eino-ext +Enter your message (empty line to exit): +``` + +## 在 DeepAgent 中启用 Skill + +本章的 “Skill 可被调用” 不是自动发生的,你需要在 Agent 构建时把 `skill` 中间件注册进去。核心就是三步: + +1. 用本地 filesystem backend(本章用 `eino-ext/adk/backend/local`)提供文件读取/Glob 能力 +2. 用 `skill.NewBackendFromFilesystem` 把 `EINO_EXT_SKILLS_DIR` 变成一个 Skill Backend +3. 用 `skill.NewMiddleware` 生成中间件,并把它塞进 DeepAgent 的 `Handlers` + +**关键代码片段(**注意:这是简化后的代码片段,不能直接运行,完整代码请参考** [cmd/ch09/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch09/main.go)):** + +```go +backend, _ := localbk.NewBackend(ctx, &localbk.Config{}) + +skillBackend, _ := skill.NewBackendFromFilesystem(ctx, &skill.BackendFromFilesystemConfig{ + Backend: backend, + BaseDir: skillsDir, // = $EINO_EXT_SKILLS_DIR +}) +skillMiddleware, _ := skill.NewMiddleware(ctx, &skill.Config{ + Backend: skillBackend, +}) + +agent, _ := deep.New(ctx, &deep.Config{ + ChatModel: cm, + Backend: backend, + StreamingShell: backend, + Handlers: []adk.ChatModelAgentMiddleware{ + skillMiddleware, + // ... 其他中间件,比如 approval/safeTool/retry 等 + }, +}) +``` + +补充说明: + +- 本 quickstart 为了保证 “没配置 skills 也能跑”,在代码里对 `EINO_EXT_SKILLS_DIR` 做了存在性检查:目录存在才注册 `skillMiddleware`;否则跳过(此时仍可对话与使用 RAG 工具)。 +- Skill 工具的入参是一个 JSON:`{"skill": ""}`,例如 `{"skill":"eino-guide"}`。 + +## 快速验证(推荐) + +启动后输入一条指令,明确要求模型调用 skill 工具(用于验证 skills 已被发现且可被加载): + +```text +Use the skill tool with skill="eino-guide" and tell me what the entry point is for getting started. +``` + +你应当能在控制台看到类似输出: + +- `[tool result] Launching skill: eino-guide` +- Tool result 中包含 `Base directory for this skill: .../eino-guide` + +## 你会看到什么 + +- 当模型调用 skill 工具时,控制台会打印: + - `[tool call] ...` + - `[tool result] ...`(对结果做了截断展示) +- 会话保存在 `SESSION_DIR`(默认 `./data/sessions`),支持恢复: + - `go run ./cmd/ch09 --session ` diff --git a/quickstart/chatwitheino/docs/ch10_a2ui.md b/quickstart/chatwitheino/docs/ch10_a2ui.md new file mode 100644 index 0000000..0e0b665 --- /dev/null +++ b/quickstart/chatwitheino/docs/ch10_a2ui.md @@ -0,0 +1,242 @@ +--- +title: "第十章:A2UI 协议(流式 UI 组件)" +--- + +本章目标:实现 A2UI 协议,将 Agent 的输出渲染为流式 UI 组件。 + +## 重要说明:A2UI 的边界 + +A2UI 并不属于 Eino 框架本身的范畴,它是一个业务层的 UI 协议/渲染方案。本章把 A2UI 集成进前面章节逐步构建出来的 Agent,是为了提供一个端到端、可落地的完整示例:从模型调用、工具调用、工作流编排,到最终把结果以更友好的 UI 方式呈现出来。 + +在真实业务场景中,你完全可以根据产品形态选择不同的 UI 形式,例如: + +- Web / App:自定义组件、表格、卡片、图表等 +- IM/办公套件:消息卡片、交互式表单 +- 命令行:纯文本或 TUI(终端 UI) + +Eino 更关注“可组合的智能执行与编排能力”,至于“如何呈现给用户”,属于业务层可以自由扩展的一环。 + +## 代码位置 + +- 入口代码:[main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/main.go) +- Agent 构建:[agent.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/agent.go) +- 服务端路由:[server/server.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/server/server.go) +- A2UI 子集实现:[a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go) +- A2UI 事件流转换:[a2ui/streamer.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/streamer.go) +- 前端页面:[static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html) + +## 前置条件 + +与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark) + +## 运行 + +在 `quickstart/chatwitheino` 目录下执行: + +```bash +go run . +``` + +输出示例: + +```text +starting server on http://localhost:8080 +``` + +### (可选)启用 ch09 的 skills 能力 + +最终 Web 版使用的 Agent 构建逻辑与 Chapter 9 对齐:当 `EINO_EXT_SKILLS_DIR` 指向一个合法 skills 目录时,会自动注册 `skill` 中间件,模型就能按需调用 `skill` 工具加载 `eino-guide` / `eino-component` / `eino-compose` / `eino-agent`。 + +```bash +go run ./scripts/sync_eino_ext_skills.go -src /path/to/eino-ext -dest ./skills/eino-ext -clean +EINO_EXT_SKILLS_DIR="$(pwd)/skills/eino-ext" go run . +``` + +## 从文本到 UI:为什么需要 A2UI + +前八章我们实现的 Agent 只输出文本,但现代 AI 应用需要更丰富的交互。 + +**纯文本输出的局限:** +- 无法展示结构化数据(表格、列表、卡片等) +- 无法实时更新(进度条、状态变化等) +- 无法嵌入交互元素(按钮、表单、链接等) +- 无法支持多媒体(图片、视频、音频等) + +**A2UI 的定位:** +- **A2UI 是 Agent 到 UI 的协议**:定义了 Agent 输出如何映射到 UI 组件 +- **A2UI 支持流式渲染**:组件可以实时更新,无需等待完整响应 +- **A2UI 是声明式的**:Agent 只需声明"显示什么",UI 负责渲染 + +**简单类比:** +- **纯文本输出** = "终端命令行"(只能显示文本) +- **A2UI** = "Web 应用"(可以显示任何 UI 组件) + +## 关键概念 + +### A2UI v0.8 子集(本示例的边界) + +本 quickstart 并没有实现一个“完整的 A2UI 标准库”,而是实现了一个 **A2UI v0.8 的子集**:目标是把 Agent 的事件流,以稳定、可增量渲染的 UI 组件树方式推给浏览器。 + +当前实现的 A2UI 消息类型与组件类型,以 [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go) 为准。 + +### A2UI 消息:BeginRendering / SurfaceUpdate / DataModelUpdate / InterruptRequest + +每一行 SSE(`data: {...}`)承载一个 A2UI Message,Message 是一个“信封结构”,每次只会出现一个字段: + +**关键代码片段(**注意:这是简化后的代码片段,不能直接运行,完整代码请参考** [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go)):** + +```go +type Message struct { + BeginRendering *BeginRenderingMsg + SurfaceUpdate *SurfaceUpdateMsg + DataModelUpdate *DataModelUpdateMsg + DeleteSurface *DeleteSurfaceMsg + InterruptRequest *InterruptRequestMsg +} +``` + +其中: + +- `BeginRendering`:告诉前端“开始渲染一个 surface(会话)”,并指定根节点 ID +- `SurfaceUpdate`:新增/更新一批组件(组件是一个树,用 `id` 互相引用) +- `DataModelUpdate`:更新 data bindings(用于把流式文本增量更新到某个 Text 组件) +- `InterruptRequest`:当 Agent 触发 interrupt(例如审批)时,通知前端展示批准/拒绝入口 + +### A2UI 组件:Text / Column / Card / Row + +本示例 UI 组件只实现了 4 种(见 [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go)): + +- `Text`:文本渲染(支持 `usageHint` 区分 caption/body/title);当 `dataKey` 存在时,文本来自 `DataModelUpdate` +- `Column` / `Row`:布局(children 是组件 ID 列表) +- `Card`:卡片容器(children 是组件 ID 列表) + +## A2UI 的实现:把 AgentEvent 转成 A2UI SSE + +最终 Web 版的核心链路是: + +- 后端运行 Agent,得到 `*adk.AsyncIterator[*adk.AgentEvent]` +- 把事件流转换为 A2UI JSONL/SSE 流输出给浏览器(见 [a2ui/streamer.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/streamer.go)) +- 前端解析 SSE 的 `data:` 行并渲染组件树(见 [static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html)) + +### 服务端路由(高层) + +与 A2UI 相关的关键接口(见 [server/server.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/server/server.go)): + +- `GET /`:返回前端页面 `static/index.html` +- `POST /sessions/:id/chat`:返回 SSE 流(A2UI messages),把 Agent 运行结果边跑边渲染到 UI +- `GET /sessions/:id/render`:返回 JSONL(A2UI messages),用于“选中会话时回放历史” +- `POST /sessions/:id/approve`:处理 interrupt 的批准/拒绝并继续返回 SSE 流 + +### 事件流转换(高层) + +服务端把 `Runner.Run(...)` 的事件流交给 `a2ui.StreamToWriter(...)`,后者负责: + +- 对 user/assistant/tool 的输出做拆分 +- 把 tool call / tool result 渲染成 “chip 卡片” +- 把 assistant 的流式 token 做成 `DataModelUpdate`,实现“边生成边渲染” +- 遇到 interrupt 时发送 `InterruptRequest`,并暂停等待人类批准 + +## 前端集成:fetch + SSE(不是 WebSocket) + +- 前端通过 `fetch('/sessions/:id/chat')` 发起请求,然后从 `res.body` 读取流式字节,按行切分并解析 `data: {...}` 的 JSON(见 [static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html))。 + +**关键代码片段(**注意:这是简化后的代码片段,不能直接运行,完整代码请参考** [static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html)):** + +```javascript +const res = await fetch(`/sessions/${id}/chat`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({message}), +}); + +const reader = res.body.getReader(); +const decoder = new TextDecoder(); +let buffer = ''; +while (true) { + const {done, value} = await reader.read(); + if (done) break; + buffer += decoder.decode(value, {stream: true}); + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('data:')) { + const jsonStr = trimmed.slice(5).trimStart(); + processA2UIMessage(JSON.parse(jsonStr)); + } + } +} +``` + +## A2UI 流式渲染流程(概览) + +``` +┌─────────────────────────────────────────┐ +│ 用户:分析这个文件 │ +└─────────────────────────────────────────┘ + ↓ + ┌──────────────────────┐ + │ Agent 开始处理 │ + │ A2UI: AddText │ + │ "正在分析..." │ + └──────────────────────┘ + ↓ + ┌──────────────────────┐ + │ 调用 Tool │ + │ A2UI: AddProgress │ + │ 进度: 0% │ + └──────────────────────┘ + ↓ + ┌──────────────────────┐ + │ Tool 执行中 │ + │ A2UI: UpdateProgress│ + │ 进度: 50% │ + └──────────────────────┘ + ↓ + ┌──────────────────────┐ + │ Tool 完成 │ + │ A2UI: tool result │ + └──────────────────────┘ + ↓ + ┌──────────────────────┐ + │ 显示结果 │ + │ A2UI: DataModelUpdate│ + │ (流式更新 assistant)│ + └──────────────────────┘ +``` + +## 本章小结 + +- **A2UI**:Agent 到 UI 的协议,定义了 Agent 输出如何映射到 UI 组件 +- **子集实现**:本示例只实现了 Text/Column/Card/Row 与 data binding +- **流式输出**:后端以 SSE 推送 A2UI JSONL,前端增量渲染组件树 +- **事件到 UI**:把 `AgentEvent` 转为 `tool call / tool result / assistant stream` 的可视化输出 + +## 系列收尾:这个 Quickstart Agent 的完整愿景 + +到本章为止,我们用一个可以实际运行的 Agent 串起了 Eino 的核心能力。你可以把它理解为一个可扩展的“端到端 Agent 应用骨架”: + +- 运行时:Runner 驱动执行,支持流式输出与事件模型 +- 工具层:Filesystem / Shell 等 Tool 能力接入,工具错误可被安全处理 +- 中间件:可插拔的 middleware/handler,用于错误处理、重试、审批等横切能力 +- 可观测:callbacks/trace 能力把关键链路打通,便于调试与线上观测 +- 人机协作:interrupt/resume + checkpoint 支持审批、补参、分支选择等交互式流程 +- 确定性编排:compose(graph/chain/workflow)把复杂业务流程组织为可维护、可复用的执行图 +- 业务交付:像 A2UI 这样的 UI 集成,属于业务层自由选择的一环,用来把 Agent 能力以合适的产品形态呈现给用户 + +你可以在这个骨架上逐步替换/扩展任意环节:模型、工具、存储、工作流、前端渲染协议,而不需要推倒重来。 + +## 扩展思考 + +**其他组件类型:** +- 图表组件(折线图、柱状图、饼图) +- 地图组件 +- 时间线组件 +- 树形组件 +- 标签页组件 + +**高级功能:** +- 组件交互(点击、拖拽、输入) +- 条件渲染 +- 组件动画 +- 响应式布局 diff --git a/quickstart/chatwitheino/docs/documentation_review_report.md b/quickstart/chatwitheino/docs/documentation_review_report.md deleted file mode 100644 index db95b58..0000000 --- a/quickstart/chatwitheino/docs/documentation_review_report.md +++ /dev/null @@ -1,752 +0,0 @@ -# ChatWithEino Quickstart 文档审查报告 - -**审查日期**: 2026-03-12 -**审查范围**: 第1-9章完整文档 -**审查视角**: 新人开发者(首次接触 Eino 框架) - ---- - -## 📊 执行摘要 - -本次审查以新人视角完整阅读了 ChatWithEino Quickstart 系列的全部 9 章文档,共发现 **20 个主要问题**,归纳为 **5 大类核心问题**。 - -### 总体评价 - -**优点:** -- ✅ 循序渐进的章节设计,从简单到复杂 -- ✅ 每章都有可运行的代码示例 -- ✅ 第三章明确区分框架层和业务层概念 -- ✅ 代码片段有清晰的警告说明 -- ✅ 第九张明确说明了 A2UI 的边界 - -**主要问题:** -- ❌ 概念引入节奏过快,每章引入 3-5 个新概念 -- ❌ 缺少"为什么"的解释,更多关注"是什么"和"怎么做" -- ❌ 代码示例缺少上下文,变量来源不清晰 -- ❌ 术语不一致,同一概念在不同章节有不同名称 -- ❌ 缺少错误处理和调试指导 - ---- - -## 🔴 高优先级问题(影响理解) - -### 问题 1:第一章缺少 Eino 框架介绍 - -**位置**: 第一章开头 - -**问题描述**: -- 文档直接进入 Component 接口,但没有说明 Eino 是什么、ADK 是什么 -- 新人不知道自己在学什么框架,缺乏全局认知 - -**新人疑问**: -> "Eino 框架是什么?我为什么要用它?它解决了什么问题?" - -**改进建议**: -在第一章开头添加: - -```markdown -## Eino 框架简介 - -**Eino 是什么?** - -Eino 是一个 Go 语言实现的 AI 应用开发框架(Agent Development Kit),旨在帮助开发者快速构建可扩展、可维护的 AI 应用。 - -**Eino 解决什么问题?** - -1. **模型抽象**:统一不同 LLM 提供商的接口(OpenAI、Ark、Claude 等) -2. **能力组合**:通过 Component 接口实现可替换、可组合的能力单元 -3. **编排框架**:提供 Agent、Graph、Chain 等编排抽象 -4. **运行时支持**:支持流式输出、中断恢复、状态管理等 - -**Eino 的核心价值**: - -- **开发效率**:开箱即用的组件和工具 -- **可维护性**:清晰的抽象和接口设计 -- **可扩展性**:易于添加新组件和能力 -``` - -**优先级**: 🔴 高 - 影响读者对整个系列的认知 - ---- - -### 问题 2:第二章概念跳跃严重 - -**位置**: 第二章开头 - -**问题描述**: -- 第一章只讲了 `ChatModel`,第二章突然引入 `Agent`、`Runner`、`AgentEvent`、`AsyncIterator` 四个新概念 -- 没有解释为什么需要这些抽象,以及它们之间的关系 - -**新人疑问**: -> - "为什么突然需要 Agent?第一章的 ChatModel 不够用吗?" -> - "Runner 和 Agent 是什么关系?" -> - "AsyncIterator 是什么?为什么要用异步迭代器?" - -**改进建议**: - -1. **拆分概念引入**:将第二章拆分为多个小节: - - 2.1 从 ChatModel 到 Agent:为什么需要 Agent - - 2.2 Agent 接口与 ChatModelAgent - - 2.3 Runner:Agent 的执行框架 - - 2.4 AgentEvent 与 AsyncIterator:事件驱动模型 - -2. **添加对比示例**: - -```markdown -### 为什么需要 Agent? - -**不使用 Agent 的多轮对话实现**: - -```go -// 需要手动管理历史 -history := []*schema.Message{schema.SystemMessage(instruction)} - -for { - line := readInput() - history = append(history, schema.UserMessage(line)) - - // 需要手动处理流式输出 - stream, _ := cm.Stream(ctx, history) - content := collectStream(stream) - history = append(history, schema.AssistantMessage(content, nil)) - - // 需要手动处理错误、重试、中断... -} -``` - -**使用 Agent 的多轮对话实现**: - -```go -// Agent 自动管理历史、流式输出、错误处理 -agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ - Model: cm, - Instruction: instruction, -}) - -runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent}) -events := runner.Query(ctx, line) -// Agent 自动处理一切 -``` - -**对比结论**: -- Agent 封装了历史管理、流式处理、错误处理等通用逻辑 -- Runner 提供了统一的执行框架和生命周期管理 -- 开发者只需关注业务逻辑,无需关心底层细节 -``` - -3. **解释 AsyncIterator**: - -```markdown -### AsyncIterator:异步流式读取 - -**什么是 AsyncIterator?** - -`AsyncIterator` 是一个异步迭代器,支持流式读取数据,避免阻塞。 - -**为什么需要 AsyncIterator?** - -传统迭代器会阻塞当前线程,等待所有数据准备好才返回。而 `AsyncIterator` 可以: -- 实时返回已准备好的数据 -- 支持流式输出,提升用户体验 -- 避免长时间阻塞 - -**使用示例**: - -```go -events := runner.Run(ctx, history) - -for { - event, ok := events.Next() // 非阻塞,立即返回 - if !ok { - break - } - // 实时处理每个事件 - handleEvent(event) -} -``` -``` - -**优先级**: 🔴 高 - 认知负担过重,影响学习效果 - ---- - -### 问题 3:第四章概念引入过于突然 - -**位置**: 第四章开头 - -**问题描述**: -- 前三章都没有提到 `Backend`,第四章突然引入 `Backend`、`LocalBackend`、`DeepAgent` 三个新概念 -- 没有解释为什么从 ChatModelAgent 切换到 DeepAgent - -**新人疑问**: -> - "Backend 是什么?和 Tool 是什么关系?" -> - "为什么突然用 DeepAgent?第二章的 ChatModelAgent 不用了吗?" -> - "DeepAgent 和 ChatModelAgent 有什么区别?" - -**改进建议**: - -1. **添加过渡说明**: - -```markdown -### 从 ChatModelAgent 到 DeepAgent:为什么需要更高级的 Agent - -前三章我们使用了 `ChatModelAgent`,它是最简单的 Agent 实现。但在实际应用中,我们往往需要更强大的能力。 - -**ChatModelAgent 的局限**: -- 只能调用 ChatModel,无法访问外部资源 -- 没有内置的文件系统、命令执行等能力 -- 需要手动注册和管理 Tool - -**DeepAgent 的优势**: -- 内置文件系统访问能力(通过 Backend) -- 内置命令执行能力(通过 StreamingShell) -- 自动注册常用 Tool(read_file、write_file、execute 等) -- 支持任务管理和子 Agent - -**何时使用 ChatModelAgent vs DeepAgent?** - -| 场景 | 推荐使用 | -|------|----------| -| 纯对话场景(无外部访问) | ChatModelAgent | -| 需要访问文件系统 | DeepAgent | -| 需要执行命令 | DeepAgent | -| 需要任务管理 | DeepAgent | - -**本章示例**:我们将使用 DeepAgent 来实现文件系统访问能力。 -``` - -2. **解释 Backend 概念**: - -```markdown -### Backend:文件系统操作的抽象 - -**什么是 Backend?** - -`Backend` 是 Eino 中用于文件系统操作的抽象接口,提供了统一的文件操作能力。 - -**为什么需要 Backend?** - -不同的存储后端(本地文件系统、云存储、数据库等)有不同的实现细节。Backend 接口屏蔽了这些差异,让 Agent 可以用统一的方式访问不同的存储后端。 - -**Backend 提供的能力**: -- `Read()`:读取文件内容 -- `Write()`:写入文件内容 -- `Glob()`:查找文件 -- `Grep()`:搜索内容 -- `Edit()`:编辑文件 - -**Backend 与 Tool 的关系**: -- Backend 是底层能力提供者 -- Tool 是 Agent 可调用的接口 -- DeepAgent 会自动将 Backend 的能力封装为 Tool -``` - -**优先级**: 🔴 高 - 概念跳跃影响理解 - ---- - -### 问题 4:第七章 Interrupt 机制代码过于复杂 - -**位置**: 第七章"关键概念"部分 - -**问题描述**: -- Interrupt 机制的代码示例包含中断、恢复、状态管理等多个概念 -- 代码逻辑复杂,缺少详细注释 - -**新人疑问**: -> - "`wasInterrupted` 和 `isTarget` 有什么区别?" -> - "为什么需要两次检查?" -> - "storedArgs 是什么?" - -**改进建议**: - -1. **提供简化版示例**: - -```markdown -### Interrupt 机制的简化理解 - -Interrupt 机制的核心思想:**在执行关键操作前暂停,等待用户确认后继续**。 - -**简化版实现(伪代码)**: - -```go -func myTool(ctx context.Context, args string) (string, error) { - // 第一步:检查是否已经中断过 - if !已经中断过 { - // 第一次调用:触发中断,等待用户确认 - return 中断并等待确认(args) - } - - // 第二步:恢复后,检查用户是否批准 - if 用户批准 { - // 执行实际操作 - return 执行操作(args) - } else { - // 用户拒绝 - return "操作被拒绝", nil - } -} -``` - -**完整实现(带详细注释)**: - -```go -func myTool(ctx context.Context, args string) (string, error) { - // 检查是否已经中断过 - // wasInterrupted: 是否在之前的调用中触发过中断 - // storedArgs: 中断时保存的参数 - wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx) - - if !wasInterrupted { - // 第一次调用:触发中断 - // StatefulInterrupt 会: - // 1. 保存当前状态(args) - // 2. 返回中断信号 - // 3. Agent 暂停执行,等待用户输入 - return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{ - ToolName: "my_tool", - ArgumentsInJSON: args, - }, args) // 第三个参数是保存的状态 - } - - // 恢复后:检查用户是否批准 - // isTarget: 是否是恢复到这个 Tool - // hasData: 是否有恢复数据 - // data: 用户输入的审批结果 - isTarget, hasData, data := tool.GetResumeContext[*ApprovalResult](ctx) - - if isTarget && hasData { - if data.Approved { - // 用户批准:执行操作 - return doSomething(storedArgs) - } else { - // 用户拒绝:返回拒绝原因 - return "Operation rejected by user", nil - } - } - - // 其他情况:重新中断(理论上不应该走到这里) - return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{ - ToolName: "my_tool", - ArgumentsInJSON: storedArgs, - }, storedArgs) -} -``` -``` - -2. **添加完整流程图**: - -```markdown -### Interrupt/Resume 完整流程 - -``` -┌─────────────────────────────────────────┐ -│ 用户:执行命令 echo hello │ -└─────────────────────────────────────────┘ - ↓ - ┌──────────────────────┐ - │ Agent 分析意图 │ - │ 决定调用 execute │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ ApprovalMiddleware │ - │ 拦截 Tool 调用 │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 第一次调用 Tool │ - │ wasInterrupted=false│ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 触发 Interrupt │ - │ 保存状态到 Store │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 返回 Interrupt 事件 │ - │ Agent 暂停执行 │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 显示审批提示 │ - │ "Approve? (y/n)" │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 用户输入 y/n │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ runner.Resume() │ - │ 从 Store 恢复状态 │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 第二次调用 Tool │ - │ wasInterrupted=true │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 检查用户审批结果 │ - │ data.Approved? │ - └──────────────────────┘ - ↓ - ┌──────────────────────┐ - │ 执行或拒绝 │ - └──────────────────────┘ -``` -``` - -**优先级**: 🔴 高 - 代码复杂度影响理解 - ---- - -## 🟡 中优先级问题(影响体验) - -### 问题 5:环境变量配置说明不清晰 - -**位置**: 第一章"前置条件"部分 - -**问题描述**: -```bash -export OPENAI_MODEL="gpt-4.1-mini" # 这个模型名对吗? -``` - -**新人疑问**: -> "这个模型名写错了吗?应该是 gpt-4o-mini 吧?" - -**改进建议**: -```bash -export OPENAI_API_KEY="sk-..." -export OPENAI_MODEL="gpt-4o-mini" # 或 gpt-4、gpt-3.5-turbo 等 -# 可选: -# OPENAI_BASE_URL(代理或兼容服务) -# OPENAI_BY_AZURE=true(使用 Azure OpenAI) -``` - -**优先级**: 🟡 中 - 可能导致配置错误 - ---- - -### 问题 6:代码片段缺少上下文 - -**位置**: 多个章节的代码片段 - -**问题描述**: -```go -stream, err := cm.Stream(ctx, messages) -``` - -**新人疑问**: -> "`cm` 变量没有定义,这段代码怎么跑?" - -**改进建议**: -```go -// cm 是 ChatModel 实例,已在前文创建 -// messages 是 []*schema.Message 类型的消息列表 -stream, err := cm.Stream(ctx, messages) -``` - -**优先级**: 🟡 中 - 影响代码理解 - ---- - -### 问题 7:术语不一致 - -**位置**: 多个章节 - -**问题描述**: -- 第二章使用 `Handlers`,第五章使用 `Middlewares` -- 第六章使用 `TimingOnStart`,代码中使用 `OnStart` - -**改进建议**: -统一术语,或在首次使用时说明别名关系: - -```markdown -### Middleware(也称为 Handler) - -Middleware 是 Agent 的拦截器,在文档中也称为 Handler。 -``` - -**优先级**: 🟡 中 - 造成概念混淆 - ---- - -### 问题 8:缺少 Middleware 执行顺序说明 - -**位置**: 第五章 - -**问题描述**: -```go -Handlers: []adk.ChatModelAgentMiddleware{ - &safeToolMiddleware{}, - &approvalMiddleware{}, // 哪个先执行? -} -``` - -**新人疑问**: -> "Middleware 是按数组顺序执行吗?还是反序?" - -**改进建议**: -```markdown -### Middleware 执行顺序 - -Middleware 采用**洋葱模型**: -- **请求阶段**:按数组顺序执行(正序) -- **响应阶段**:按数组逆序执行(反序) - -**示例**: - -```go -Handlers: []adk.ChatModelAgentMiddleware{ - &middlewareA{}, // 请求第1个执行,响应最后执行 - &middlewareB{}, // 请求第2个执行,响应倒数第2执行 - &middlewareC{}, // 请求第3个执行,响应倒数第3执行 -} -``` - -**执行流程**: - -``` -请求 → A → B → C → Tool → C → B → A → 响应 -``` -``` - -**优先级**: 🟡 中 - 影响代码正确性 - ---- - -### 问题 9:缺少错误处理和调试指导 - -**位置**: 多个章节 - -**问题描述**: -- 文档假设一切顺利,没有展示错误场景 -- 缺少调试方法和常见问题解决 - -**改进建议**: -添加常见错误和解决方法: - -```markdown -### 常见错误及解决方法 - -#### 错误 1:API Key 无效 - -``` -Error: invalid api key -``` - -**解决方法**: -- 检查 `OPENAI_API_KEY` 环境变量是否正确 -- 确认 API Key 没有过期 - -#### 错误 2:模型不存在 - -``` -Error: model not found -``` - -**解决方法**: -- 检查 `OPENAI_MODEL` 环境变量 -- 确认使用正确的模型名称 - -#### 错误 3:Tool 调用失败 - -``` -Error: tool execution failed -``` - -**解决方法**: -- 检查 Tool 参数是否正确 -- 查看 Tool 的错误日志 -- 使用 Callback 机制追踪执行过程 -``` - -**优先级**: 🟡 中 - 影响调试效率 - ---- - -### 问题 10:PROJECT_ROOT 配置复杂 - -**位置**: 第四章 - -**问题描述**: -```bash -export PROJECT_ROOT=/path/to/eino -``` - -**新人疑问**: -> "我怎么知道我的路径对不对?有没有验证方法?" - -**改进建议**: -```bash -# 设置项目根目录 -export PROJECT_ROOT=/path/to/eino - -# 验证路径是否正确 -ls $PROJECT_ROOT/adk # 应该存在 adk 目录 -ls $PROJECT_ROOT/components # 应该存在 components 目录 - -# 如果路径错误,会看到类似错误: -# Error: directory not found -``` - -**优先级**: 🟡 中 - 可能导致运行失败 - ---- - -## 🟢 低优先级问题(锦上添花) - -### 问题 11:JSONL 格式示例不够清晰 - -**位置**: 第三章 - -**改进建议**: -```jsonl -# 第一行:Session 元数据 -{"type":"session","id":"083d16da-...","created_at":"2026-03-11T10:00:00Z"} - -# 后续行:对话消息 -{"role":"user","content":"你好,我是谁?"} -{"role":"assistant","content":"你好!我暂时不知道你是谁..."} -``` - -**优先级**: 🟢 低 - 不影响主要理解 - ---- - -### 问题 12:缺少前端完整示例 - -**位置**: 第九章 - -**改进建议**: -提供完整的前端示例代码或链接到示例仓库。 - -**优先级**: 🟢 低 - A2UI 已说明是业务层实现 - ---- - -## 📈 改进优先级总结 - -### 🔴 立即修复(影响理解) - -| 问题 | 位置 | 影响 | -|------|------|------| -| 缺少 Eino 框架介绍 | 第一章 | 新人不知道在学什么 | -| 概念跳跃严重 | 第二章 | 认知负担过重 | -| 概念引入突然 | 第四章 | 无法理解为什么切换 | -| Interrupt 代码复杂 | 第七章 | 无法理解中断机制 | - -### 🟡 近期优化(影响体验) - -| 问题 | 位置 | 影响 | -|------|------|------| -| 环境变量配置不清晰 | 第一章 | 可能配置错误 | -| 代码缺少上下文 | 多章节 | 影响代码理解 | -| 术语不一致 | 多章节 | 概念混淆 | -| 缺少执行顺序说明 | 第五章 | 影响代码正确性 | -| 缺少错误处理指导 | 多章节 | 影响调试效率 | -| PROJECT_ROOT 配置复杂 | 第四章 | 可能运行失败 | - -### 🟢 长期改进(锦上添花) - -| 问题 | 位置 | 影响 | -|------|------|------| -| JSONL 格式示例不清晰 | 第三章 | 理解细节 | -| 缺少前端完整示例 | 第九章 | 实现细节 | - ---- - -## 🎯 核心改进建议 - -### 1. 调整概念引入节奏 - -**原则**:每章最多引入 2 个核心概念 - -**实施方法**: -- 将复杂章节拆分为多个小节 -- 为每个新概念单独设立小节 -- 提供充分的背景和动机说明 - -### 2. 添加对比示例 - -**原则**:展示"有/无某个抽象"的代码对比 - -**实施方法**: -- 在引入新概念时,先展示"不使用该概念"的代码 -- 再展示"使用该概念"的代码 -- 对比两者的复杂度和可维护性 - -### 3. 统一术语 - -**原则**:全书使用一致的术语 - -**实施方法**: -- 建立术语表 -- 在首次使用时说明别名关系 -- 全书审查确保一致性 - -### 4. 补充上下文 - -**原则**:代码片段说明关键变量来源 - -**实施方法**: -- 在代码片段前添加注释 -- 说明变量的类型和来源 -- 提供完整的上下文信息 - -### 5. 完善流程图 - -**原则**:添加完整的时序图和状态转换图 - -**实施方法**: -- 为复杂流程添加时序图 -- 展示用户交互环节 -- 标注关键决策点 - ---- - -## 📚 附录:文档质量检查清单 - -建议在每章完成后使用以下清单自查: - -### 内容完整性 -- [ ] 是否说明了"为什么需要这个概念"? -- [ ] 是否提供了可运行的代码示例? -- [ ] 是否解释了关键术语? -- [ ] 是否说明了前置知识? - -### 代码质量 -- [ ] 代码片段是否有上下文说明? -- [ ] 变量是否有类型和来源说明? -- [ ] 是否有"不能直接运行"的警告? -- [ ] 是否使用了正确的模型名称? - -### 用户体验 -- [ ] 概念引入是否循序渐进? -- [ ] 是否有对比示例? -- [ ] 是否有错误处理指导? -- [ ] 是否有调试方法? - -### 视觉呈现 -- [ ] 是否有流程图或时序图? -- [ ] 表格是否清晰易读? -- [ ] 代码格式是否正确? -- [ ] 术语是否一致? - ---- - -## 📝 后续行动建议 - -1. **立即行动**:修复 4 个高优先级问题 -2. **本周完成**:优化 6 个中优先级问题 -3. **持续改进**:收集读者反馈,迭代优化 -4. **建立机制**:使用检查清单确保新文档质量 - ---- - -**报告结束** - -本报告基于新人视角的完整阅读体验,旨在帮助改进文档质量,让更多开发者能够轻松学习和使用 Eino 框架。 diff --git a/quickstart/chatwitheino/scripts/sync_eino_ext_skills.go b/quickstart/chatwitheino/scripts/sync_eino_ext_skills.go new file mode 100644 index 0000000..438cc1d --- /dev/null +++ b/quickstart/chatwitheino/scripts/sync_eino_ext_skills.go @@ -0,0 +1,255 @@ +/* + * Copyright 2026 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +func main() { + srcFlag := flag.String("src", "", "source dir: eino-ext repo root, skills dir, or installed skills root") + destFlag := flag.String("dest", "", "destination dir (default: ./skills/eino-ext)") + cleanFlag := flag.Bool("clean", false, "remove destination dir before syncing") + flag.Parse() + + src := strings.TrimSpace(*srcFlag) + if src == "" { + src = strings.TrimSpace(os.Getenv("EINO_EXT_SKILLS_SRC")) + } + if src == "" { + fmt.Fprintln(os.Stderr, "missing -src (or set EINO_EXT_SKILLS_SRC)") + os.Exit(2) + } + + dest := strings.TrimSpace(*destFlag) + if dest == "" { + dest = strings.TrimSpace(os.Getenv("EINO_EXT_SKILLS_DEST")) + } + if dest == "" { + dest = filepath.Join(".", "skills", "eino-ext") + } + + srcAbs, err := filepath.Abs(src) + if err == nil { + src = srcAbs + } + destAbs, err := filepath.Abs(dest) + if err == nil { + dest = destAbs + } + + srcBase := resolveSourceBase(src) + if srcBase == "" { + fmt.Fprintf(os.Stderr, "invalid -src: %s\n", src) + os.Exit(2) + } + + if *cleanFlag { + if err := os.RemoveAll(dest); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + if err := os.MkdirAll(dest, 0755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + want := []string{"eino-agent", "eino-component", "eino-compose", "eino-guide"} + var copied []string + for _, name := range want { + srcDir := filepath.Join(srcBase, name) + if !isDir(srcDir) { + continue + } + destDir := filepath.Join(dest, name) + if err := copyDir(srcDir, destDir); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := ensureSkillMD(destDir, name); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + copied = append(copied, name) + } + + if len(copied) == 0 { + fmt.Fprintf(os.Stderr, "no skills found under %s (expected: %s)\n", srcBase, strings.Join(want, ", ")) + os.Exit(1) + } + + sort.Strings(copied) + fmt.Printf("Synced skills into %s: %s\n", dest, strings.Join(copied, ", ")) + fmt.Printf("Run with: EINO_EXT_SKILLS_DIR=%s go run ./cmd/ch09\n", dest) +} + +func resolveSourceBase(src string) string { + if isDir(filepath.Join(src, "skills")) { + return filepath.Join(src, "skills") + } + if isDir(src) { + return src + } + return "" +} + +func ensureSkillMD(destDir, name string) error { + skillPath := filepath.Join(destDir, "SKILL.md") + if fileExists(skillPath) { + return nil + } + + entry := pickEntryFile(destDir) + desc := defaultDescription(name) + + content := "---\n" + + "name: " + name + "\n" + + "description: " + desc + "\n" + + "---\n\n" + + "Use the documentation under this directory to answer questions about Eino.\n\n" + if entry != "" { + content += "Start with: " + entry + "\n" + } else { + content += "Start by listing markdown files in this directory.\n" + } + + return os.WriteFile(skillPath, []byte(content), 0644) +} + +func defaultDescription(name string) string { + switch name { + case "eino-guide": + return "Entry point and navigation for Eino framework docs." + case "eino-component": + return "Component interfaces and implementations reference." + case "eino-compose": + return "Orchestration (Graph/Chain/Workflow) reference." + case "eino-agent": + return "ADK agents, middleware, runner reference." + default: + return "Eino skills documentation." + } +} + +func pickEntryFile(dir string) string { + candidates := []string{ + "README.md", + "readme.md", + "index.md", + "INDEX.md", + } + for _, c := range candidates { + p := filepath.Join(dir, c) + if fileExists(p) { + return c + } + } + + var first string + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == ".github" || d.Name() == "node_modules" { + return filepath.SkipDir + } + return nil + } + if strings.EqualFold(d.Name(), "SKILL.md") { + return nil + } + if strings.HasSuffix(strings.ToLower(d.Name()), ".md") { + rel, rerr := filepath.Rel(dir, path) + if rerr == nil { + first = filepath.ToSlash(rel) + return errorsStopWalk{} + } + } + return nil + }) + return first +} + +type errorsStopWalk struct{} + +func (errorsStopWalk) Error() string { return "stop" } + +func copyDir(src, dest string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + if d.IsDir() { + return os.MkdirAll(filepath.Join(dest, rel), 0755) + } + if !d.Type().IsRegular() { + return nil + } + destPath := filepath.Join(dest, rel) + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return err + } + return copyFile(path, destPath) + }) +} + +func copyFile(src, dest string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dest) + if err != nil { + return err + } + defer func() { + _ = out.Close() + }() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} + +func isDir(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.IsDir() +} + +func fileExists(path string) bool { + fi, err := os.Stat(path) + return err == nil && !fi.IsDir() +}