feat(chatwitheino): add filesystem-backed skills (#185)

drew/english
shentongmartin 2 months ago committed by GitHub
parent 9b0c95dad9
commit a2814e625d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

1
.gitignore vendored

@ -48,6 +48,7 @@ log/
CLAUDE.md
quickstart/chatwithdoc/data/sessions/
quickstart/chatwithdoc/data/workspace/
quickstart/chatwitheino/skills/
*.jsonl
*.txt

@ -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.

@ -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")
}

@ -56,7 +56,8 @@ ChatWithEino 是一个基于 Eino 框架构建的智能助手,能够帮助开
| **第六章** | Callback | 回调机制,监控 Agent 执行过程 | 可观测性 |
| **第七章** | Interrupt 与 Resume | 中断与恢复,支持长时间任务 | 可靠性增强 |
| **第八章** | Graph 与 Tool | 使用 Graph 编排复杂工作流 | 复杂编排能力 |
| **第九章** | A2UI | Agent 到 UI 的集成方案 | 生产级应用 |
| **第九章** | Skill | 使用 Skill 中间件加载并复用技能文档 | 知识复用能力 |
| **最终章** | A2UI | Agent 到 UI 的集成方案 | 生产级应用 |
**为什么这样设计?**

@ -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)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 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 支持审批、补参、分支选择等交互式流程
- 确定性编排composegraph/chain/workflow把复杂业务流程组织为可维护、可复用的执行图
- 业务交付:像 A2UI 这样的 UI 集成,属于业务层自由选择的一环,用来把 Agent 能力以合适的产品形态呈现给用户
你可以在这个骨架上逐步替换/扩展任意环节:模型、工具、存储、工作流、前端渲染协议,而不需要推倒重来。
## 扩展思考
**其他组件类型:**
- 图表组件(折线图、柱状图、饼图)
- 地图组件
- 时间线组件
- 树形组件
- 标签页组件
**高级功能:**
- 组件交互(点击、拖拽、输入)
- 条件渲染
- 组件动画
- 响应式布局

@ -0,0 +1,137 @@
---
title: "第九章SkillConsole"
---
本章目标在第八章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)
## 前置条件
- 与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 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` 仓库本地路径(脚本会自动读取 `<src>/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/<skillName>/SKILL.md`
同步命令(推荐):
```bash
go run ./scripts/sync_eino_ext_skills.go -src /path/to/eino-ext -dest ./skills/eino-ext -clean
```
说明:
- `-src` 支持两种形式:
- `eino-ext` 仓库根目录(脚本会自动读取 `<src>/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": "<skillName>"}`,例如 `{"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 <id>`

@ -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)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 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 MessageMessage 是一个“信封结构”,每次只会出现一个字段:
**关键代码片段(**注意:这是简化后的代码片段,不能直接运行,完整代码请参考** [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`:返回 JSONLA2UI 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 支持审批、补参、分支选择等交互式流程
- 确定性编排composegraph/chain/workflow把复杂业务流程组织为可维护、可复用的执行图
- 业务交付:像 A2UI 这样的 UI 集成,属于业务层自由选择的一环,用来把 Agent 能力以合适的产品形态呈现给用户
你可以在这个骨架上逐步替换/扩展任意环节:模型、工具、存储、工作流、前端渲染协议,而不需要推倒重来。
## 扩展思考
**其他组件类型:**
- 图表组件(折线图、柱状图、饼图)
- 地图组件
- 时间线组件
- 树形组件
- 标签页组件
**高级功能:**
- 组件交互(点击、拖拽、输入)
- 条件渲染
- 组件动画
- 响应式布局

@ -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 RunnerAgent 的执行框架
- 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
- 自动注册常用 Toolread_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
### 常见错误及解决方法
#### 错误 1API Key 无效
```
Error: invalid api key
```
**解决方法**
- 检查 `OPENAI_API_KEY` 环境变量是否正确
- 确认 API Key 没有过期
#### 错误 2模型不存在
```
Error: model not found
```
**解决方法**
- 检查 `OPENAI_MODEL` 环境变量
- 确认使用正确的模型名称
#### 错误 3Tool 调用失败
```
Error: tool execution failed
```
**解决方法**
- 检查 Tool 参数是否正确
- 查看 Tool 的错误日志
- 使用 Callback 机制追踪执行过程
```
**优先级**: 🟡 中 - 影响调试效率
---
### 问题 10PROJECT_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
```
**优先级**: 🟡 中 - 可能导致运行失败
---
## 🟢 低优先级问题(锦上添花)
### 问题 11JSONL 格式示例不够清晰
**位置**: 第三章
**改进建议**:
```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 框架。

@ -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()
}
Loading…
Cancel
Save