create-agent

create-agent

使用 OpenRouter SDK、可擴展鉤子與可選的 Ink TUI 快速建立模組化 AI 代理

29星標
9分支
更新於 2026/1/31
SKILL.md
唯讀
名稱
create-agent
描述

使用 OpenRouter SDK、可擴展鉤子與可選的 Ink TUI 快速建立模組化 AI 代理

使用 OpenRouter 建立模組化 AI 代理

此技能幫助你建立一個模組化 AI 代理,具備以下功能:

  • 獨立代理核心 - 可獨立執行,並透過鉤子擴展
  • OpenRouter SDK - 統一存取 300 多種語言模型
  • 可選的 Ink TUI - 美觀的終端機介面(與代理邏輯分離)

架構

┌─────────────────────────────────────────────────────┐
│                    你的應用程式                       │
├─────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  │
│  │   Ink TUI   │  │  HTTP API   │  │   Discord   │  │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘  │
│         │                │                │         │
│         └────────────────┼────────────────┘         │
│                          ▼                          │
│              ┌───────────────────────┐              │
│              │      代理核心         │              │
│              │  (鉤子與生命週期)    │              │
│              └───────────┬───────────┘              │
│                          ▼                          │
│              ┌───────────────────────┐              │
│              │   OpenRouter SDK     │              │
│              └───────────────────────┘              │
└─────────────────────────────────────────────────────┘

前置需求

https://openrouter.ai/settings/keys 取得 OpenRouter API 金鑰

⚠️ 安全性: 切勿將 API 金鑰提交到版本控制。請使用環境變數。

專案設定

步驟 1:初始化專案

mkdir my-agent && cd my-agent
npm init -y
npm pkg set type="module"

步驟 2:安裝相依套件

npm install @openrouter/sdk zod eventemitter3
npm install ink react  # 選用:僅 TUI 需要
npm install -D typescript @types/react tsx

步驟 3:建立 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

步驟 4:在 package.json 中加入腳本

{
  "scripts": {
    "start": "tsx src/cli.tsx",
    "start:headless": "tsx src/headless.ts",
    "dev": "tsx watch src/cli.tsx"
  }
}

檔案結構

src/
├── agent.ts        # 獨立代理核心,含鉤子
├── tools.ts        # 工具定義
├── cli.tsx         # Ink TUI(選用介面)
└── headless.ts     # 無頭模式使用範例

步驟 1:含鉤子的代理核心

建立 src/agent.ts - 可獨立執行的代理核心:

import { OpenRouter, tool, stepCountIs } from '@openrouter/sdk';
import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk';
import { EventEmitter } from 'eventemitter3';
import { z } from 'zod';

// 訊息類型
export interface Message {
  role: 'user' | 'assistant' | 'system';
  content: string;
}

// 代理事件(基於項目的串流模型)
export interface AgentEvents {
  'message:user': (message: Message) => void;
  'message:assistant': (message: Message) => void;
  'item:update': (item: StreamableOutputItem) => void;  // 項目以相同 ID 發出,依 ID 取代
  'stream:start': () => void;
  'stream:delta': (delta: string, accumulated: string) => void;
  'stream:end': (fullText: string) => void;
  'tool:call': (name: string, args: unknown) => void;
  'tool:result': (name: string, result: unknown) => void;
  'reasoning:update': (text: string) => void;  // 擴展思考內容
  'error': (error: Error) => void;
  'thinking:start': () => void;
  'thinking:end': () => void;
}


// 代理設定
export interface AgentConfig {
  apiKey: string;
  model?: string;
  instructions?: string;
  tools?: Tool<z.ZodTypeAny, z.ZodTypeAny>[];
  maxSteps?: number;
}

// Agent 類別 - 獨立於任何 UI 執行
export class Agent extends EventEmitter<AgentEvents> {
  private client: OpenRouter;
  private messages: Message[] = [];
  private config: Required<Omit<AgentConfig, 'apiKey'>> & { apiKey: string };

  constructor(config: AgentConfig) {
    super();
    this.client = new OpenRouter({ apiKey: config.apiKey });
    this.config = {
      apiKey: config.apiKey,
      model: config.model ?? 'openrouter/auto',
      instructions: config.instructions ?? 'You are a helpful assistant.',
      tools: config.tools ?? [],
      maxSteps: config.maxSteps ?? 5,
    };
  }

  // 取得對話歷史
  getMessages(): Message[] {
    return [...this.messages];
  }

  // 清除對話
  clearHistory(): void {
    this.messages = [];
  }

  // 加入系統訊息
  setInstructions(instructions: string): void {
    this.config.instructions = instructions;
  }

  // 執行時期註冊額外工具
  addTool(newTool: Tool<z.ZodTypeAny, z.ZodTypeAny>): void {
    this.config.tools.push(newTool);
  }

  // 使用基於項目的串流模型發送訊息並取得串流回應
  // 項目會以相同 ID 多次發出,內容逐步更新
  // 依 ID 取代項目,而非累積區塊
  async send(content: string): Promise<string> {
    const userMessage: Message = { role: 'user', content };
    this.messages.push(userMessage);
    this.emit('message:user', userMessage);
    this.emit('thinking:start');

    try {
      const result = this.client.callModel({
        model: this.config.model,
        instructions: this.config.instructions,
        input: this.messages.map((m) => ({ role: m.role, content: m.content })),
        tools: this.config.tools.length > 0 ? this.config.tools : undefined,
        stopWhen: [stepCountIs(this.config.maxSteps)],
      });

      this.emit('stream:start');
      let fullText = '';

      // 使用 getItemsStream() 進行基於項目的串流(建議)
      // 每次項目發出的內容都是完整的 - 依 ID 取代,不要累積
      for await (const item of result.getItemsStream()) {
        // 發出項目供 UI 狀態管理(使用以 item.id 為鍵的 Map)
        this.emit('item:update', item);

        switch (item.type) {
          case 'message':
            // 訊息項目包含逐步更新的內容
            const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text');
            if (textContent && 'text' in textContent) {
              const newText = textContent.text;
              if (newText !== fullText) {
                const delta = newText.slice(fullText.length);
                fullText = newText;
                this.emit('stream:delta', delta, fullText);
              }
            }
            break;
          case 'function_call':
            // 函式呼叫的引數逐步串流
            if (item.status === 'completed') {
              this.emit('tool:call', item.name, JSON.parse(item.arguments || '{}'));
            }
            break;
          case 'function_call_output':
            this.emit('tool:result', item.callId, item.output);
            break;
          case 'reasoning':
            // 擴展思考/推理內容
            const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text');
            if (reasoningText && 'text' in reasoningText) {
              this.emit('reasoning:update', reasoningText.text);
            }
            break;
          // 其他項目類型:web_search_call, file_search_call, image_generation_call
        }
      }

      // 若串流未擷取到最終文字,則取得最終文字
      if (!fullText) {
        fullText = await result.getText();
      }

      this.emit('stream:end', fullText);

      const assistantMessage: Message = { role: 'assistant', content: fullText };
      this.messages.push(assistantMessage);
      this.emit('message:assistant', assistantMessage);

      return fullText;
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      this.emit('error', error);
      throw error;
    } finally {
      this.emit('thinking:end');
    }
  }

  // 不經串流發送(適合程式化使用)
  async sendSync(content: string): Promise<string> {
    const userMessage: Message = { role: 'user', content };
    this.messages.push(userMessage);
    this.emit('message:user', userMessage);

    try {
      const result = this.client.callModel({
        model: this.config.model,
        instructions: this.config.instructions,
        input: this.messages.map((m) => ({ role: m.role, content: m.content })),
        tools: this.config.tools.length > 0 ? this.config.tools : undefined,
        stopWhen: [stepCountIs(this.config.maxSteps)],
      });

      const fullText = await result.getText();
      const assistantMessage: Message = { role: 'assistant', content: fullText };
      this.messages.push(assistantMessage);
      this.emit('message:assistant', assistantMessage);

      return fullText;
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      this.emit('error', error);
      throw error;
    }
  }
}

// 工廠函式,方便建立

export function createAgent(config: AgentConfig): Agent {
  return new Agent(config);
}

步驟 2:定義工具

建立 src/tools.ts

import { tool } from '@openrouter/sdk';
import { z } from 'zod';

export const timeTool = tool({
  name: 'get_current_time',
  description: 'Get the current date and time',
  inputSchema: z.object({
    timezone: z.string().optional().describe('Timezone (e.g., "UTC", "America/New_York")'),
  }),
  execute: async ({ timezone }) => {
    return {
      time: new Date().toLocaleString('en-US', { timeZone: timezone || 'UTC' }),
      timezone: timezone || 'UTC',
    };
  },
});

export const calculatorTool = tool({
  name: 'calculate',
  description: 'Perform mathematical calculations',
  inputSchema: z.object({
    expression: z.string().describe('Math expression (e.g., "2 + 2", "sqrt(16)")'),
  }),
  execute: async ({ expression }) => {
    // Simple safe eval for basic math
    const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, '');
    const result = Function(`"use strict"; return (${sanitized})`)();
    return { expression, result };
  },
});

export const defaultTools = [timeTool, calculatorTool];

步驟 3:無頭模式使用(無 UI)

建立 src/headless.ts - 以程式化方式使用代理:

import { createAgent } from './agent.js';
import { defaultTools } from './tools.js';

async function main() {
  const agent = createAgent({
    apiKey: process.env.OPENROUTER_API_KEY!,
    model: 'openrouter/auto',
    instructions: 'You are a helpful assistant with access to tools.',
    tools: defaultTools,
  });

  // 掛載事件
  agent.on('thinking:start', () => console.log('\n🤔 Thinking...'));
  agent.on('tool:call', (name, args) => console.log(`🔧 Using ${name}:`, args));
  agent.on('stream:delta', (delta) => process.stdout.write(delta));
  agent.on('stream:end', () => console.log('\n'));
  agent.on('error', (err) => console.error('❌ Error:', err.message));

  // 互動迴圈
  const readline = await import('readline');
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log('Agent ready. Type your message (Ctrl+C to exit):\n');

  const prompt = () => {
    rl.question('You: ', async (input) => {
      if (!input.trim()) {
        prompt();
        return;
      }
      await agent.send(input);
      prompt();
    });
  };

  prompt();
}

main().catch(console.error);

執行無頭模式:OPENROUTER_API_KEY=sk-or-... npm run start:headless

步驟 4:Ink TUI(選用介面)

建立 src/cli.tsx - 使用基於項目串流的代理,打造美觀終端機介面:

import React, { useState, useEffect, useCallback } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
import type { StreamableOutputItem } from '@openrouter/sdk';
import { createAgent, type Agent, type Message } from './agent.js';
import { defaultTools } from './tools.js';

// 初始化代理(獨立於 UI 執行)
const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: 'openrouter/auto',
  instructions: 'You are a helpful assistant. Be concise.',
  tools: defaultTools,
});

function ChatMessage({ message }: { message: Message }) {
  const isUser = message.role === 'user';
  return (
    <Box flexDirection="column" marginBottom={1}>
      <Text bold color={isUser ? 'cyan' : 'green'}>
        {isUser ? '▶ You' : '◀ Assistant'}
      </Text>
      <Text wrap="wrap">{message.content}</Text>
    </Box>
  );
}

// 根據類型渲染串流項目(使用基於項目的模式)
function ItemRenderer({ item }: { item: StreamableOutputItem }) {
  switch (item.type) {
    case 'message': {
      const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text');
      const text = textContent && 'text' in textContent ? textContent.text : '';
      return (
        <Box flexDirection="column" marginBottom={1}>
          <Text bold color="green">◀ Assistant</Text>
          <Text wrap="wrap">{text}</Text>
          {item.status !== 'completed' && <Text color="gray">▌</Text>}
        </Box>
      );
    }
    case 'function_call':
      return (
        <Text color="yellow">
          {item.status === 'completed' ? '  ✓' : '  🔧'} {item.name}
          {item.status === 'in_progress' && '...'}
        </Text>
      );
    case 'reasoning': {
      const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text');
      const text = reasoningText && 'text' in reasoningText ? reasoningText.text : '';
      return (
        <Box flexDirection="column" marginBottom={1}>
          <Text bold color="magenta">💭 Thinking</Text>
          <Text wrap="wrap" color="gray">{text}</Text>
        </Box>
      );
    }
    default:
      return null;
  }
}

function InputField({
  value,
  onChange,
  onSubmit,
  disabled,
}: {
  value: string;
  onChange: (v: string) => void;
  onSubmit: () => void;
  disabled: boolean;
}) {
  useInput((input, key) => {
    if (disabled) return;
    if (key.return) onSubmit();
    else if (key.backspace || key.delete) onChange(value.slice(0, -1));
    else if (input && !key.ctrl && !key.meta) onChange(value + input);
  });

  return (
    <Box>
      <Text color="yellow">{'> '}</Text>
      <Text>{value}</Text>
      <Text color="gray">{disabled ? ' ···' : '█'}</Text>
    </Box>
  );
}

function App() {
  const { exit } = useApp();
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  // 使用以 item ID 為鍵的 Map,實現高效的 React 狀態更新(基於項目的模式)
  const [items, setItems] = useState<Map<string, StreamableOutputItem>>(new Map());

  useInput((_, key) => {
    if (key.escape) exit();
  });

  // 訂閱代理事件(使用基於項目的串流)
  useEffect(() => {
    const onThinkingStart = () => {
      setIsLoading(true);
      setItems(new Map()); // 清除新回應的項目
    };

    // 基於項目的串流:依 ID 取代項目,不要累積
    const onItemUpdate = (item: StreamableOutputItem) => {
      setItems((prev) => new Map(prev).set(item.id, item));
    };

    const onMessageAssistant = () => {
      setMessages(agent.getMessages());
      setItems(new Map()); // 清除串流項目
      setIsLoading(false);
    };

    const onError = (err: Error) => {
      setIsLoading(false);
    };

    agent.on('thinking:start', onThinkingStart);
    agent.on('item:update', onItemUpdate);
    agent.on('message:assistant', onMessageAssistant);
    agent.on('error', onError);

    return () => {
      agent.off('thinking:start', onThinkingStart);
      agent.off('item:update', onItemUpdate);
      agent.off('message:assistant', onMessageAssistant);
      agent.off('error', onError);
    };
  }, []);

  const sendMessage = useCallback(async () => {
    if (!input.trim() || isLoading) return;
    const text = input.trim();
    setInput('');
    setMessages((prev) => [...prev, { role: 'user', content: text }]);
    await agent.send(text);
  }, [input, isLoading]);

  return (
    <Box flexDirection="column" padding={1}>
      <Box marginBottom={1}>
        <Text bold color="magenta">🤖 OpenRouter Agent</Text>
        <Text color="gray"> (Esc to exit)</Text>
      </Box>

      <Box flexDirection="column" marginBottom={1}>
        {/* 渲染已完成的訊息 */}
        {messages.map((msg, i) => (
          <ChatMessage key={i} message={msg} />
        ))}

        {/* 依類型渲染串流項目(基於項目的模式) */}
        {Array.from(items.values()).map((item) => (
          <ItemRenderer key={item.id} item={item} />
        ))}
      </Box>

      <Box borderStyle="single" borderColor="gray" paddingX={1}>
        <InputField
          value={input}
          onChange={setInput}
          onSubmit={sendMessage}
          disabled={isLoading}
        />
      </Box>
    </Box>
  );
}

render(<App />);

執行 TUI:OPENROUTER_API_KEY=sk-or-... npm start

了解基於項目的串流

OpenRouter SDK 使用基於項目的串流模型 - 這是一個關鍵範例,其中項目會以相同 ID 多次發出,但內容逐步更新。你應該依 ID 取代項目,而不是累積區塊。

運作方式

每次 getItemsStream() 迭代都會產生一個包含更新內容的完整項目:

// 迭代 1:部分訊息
{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello" }] }

// 迭代 2:更新後的訊息(取代,不要附加)
{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello world" }] }

對於函式呼叫,引數會逐步串流:

// 迭代 1:部分引數
{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"q" }

// 迭代 2:完整引數
{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"query\": \"Paris\"}", status: "completed" }

為什麼項目更好

傳統方式(需要累積):

let text = '';
for await (const chunk of result.getTextStream()) {
  text += chunk;  // 手動累積
  updateUI(text);
}

項目方式(完整取代):

const items = new Map<string, StreamableOutputItem>();
for await (const item of result.getItemsStream()) {
  items.set(item.id, item);  // 依 ID 取代
  updateUI(items);
}

優點:

  • 無需手動管理區塊 - 每個項目都是完整的
  • 處理並行輸出 - 函式呼叫和訊息可以同時串流
  • 完整的 TypeScript 推斷,支援所有項目類型
  • 自然的 Map 狀態管理,完美搭配 React/UI 框架

擴展代理

加入自訂鉤子

const agent = createAgent({ apiKey: '...' });

// 記錄所有事件
agent.on('message:user', (msg) => {
  saveToDatabase('user', msg.content);
});

agent.on('message:assistant', (msg) => {
  saveToDatabase('assistant', msg.content);
  sendWebhook('new_message', msg);
});

agent.on('tool:call', (name, args) => {
  analytics.track('tool_used', { name, args });
});

agent.on('error', (err) => {
  errorReporting.capture(err);
});

搭配 HTTP 伺服器使用

import express from 'express';
import { createAgent } from './agent.js';

const app = express();
app.use(express.json());

// 每個 session 一個代理(儲存在記憶體或 Redis 中)
const sessions = new Map<string, Agent>();

app.post('/chat', async (req, res) => {
  const { sessionId, message } = req.body;

  let agent = sessions.get(sessionId);
  if (!agent) {
    agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! });
    sessions.set(sessionId, agent);
  }

  const response = await agent.sendSync(message);
  res.json({ response, history: agent.getMessages() });
});

app.listen(3000);

搭配 Discord 使用

import { Client, GatewayIntentBits } from 'discord.js';
import { createAgent } from './agent.js';

const discord = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});

const agents = new Map<string, Agent>();

discord.on('messageCreate', async (msg) => {
  if (msg.author.bot) return;

  let agent = agents.get(msg.channelId);
  if (!agent) {
    agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! });
    agents.set(msg.channelId, agent);
  }

  const response = await agent.sendSync(msg.content);
  await msg.reply(response);
});

discord.login(process.env.DISCORD_TOKEN);

代理 API 參考

建構子選項

選項 類型 預設值 說明
apiKey string 必填 OpenRouter API 金鑰
model string 'openrouter/auto' 使用的模型
instructions string 'You are a helpful assistant.' 系統提示
tools Tool[] [] 可用工具
maxSteps number 5 最大代理迴圈迭代次數

方法

方法 回傳值 說明
send(content) Promise<string> 發送訊息(串流)
sendSync(content) Promise<string> 發送訊息(無串流)
getMessages() Message[] 取得對話歷史
clearHistory() void 清除對話
setInstructions(text) void 更新系統提示
addTool(tool) void 執行時期加入工具

事件

事件 負載 說明
message:user Message 使用者訊息已加入
message:assistant Message 助理回應完成
item:update StreamableOutputItem 項目已發出(依 ID 取代,不要累積)
stream:start - 串流開始
stream:delta (delta, accumulated) 新的文字區塊
stream:end fullText 串流完成
tool:call (name, args) 工具正在被呼叫
tool:result (name, result) 工具回傳結果
reasoning:update text 擴展思考內容
thinking:start - 代理正在處理
thinking:end - 代理處理完成
error Error 發生錯誤

項目類型(來自 getItemsStream)

SDK 使用基於項目的串流模型,其中項目會以相同 ID 多次發出,但內容逐步更新。請依 ID 取代項目,而不是累積區塊。

類型 用途
message 助理文字回應
function_call 工具呼叫(含串流引數)
function_call_output 已執行工具的結果
reasoning 擴展思考內容
web_search_call 網路搜尋操作
file_search_call 檔案搜尋操作
image_generation_call 圖片生成操作

探索模型

請勿硬編碼模型 ID - 它們經常變更。請使用模型 API:

取得可用模型

interface OpenRouterModel {
  id: string;
  name: string;
  description?: string;
  context_length: number;
  pricing: { prompt: string; completion: string };
  top_provider?: { is_moderated: boolean };
}

async function fetchModels(): Promise<OpenRouterModel[]> {
  const res = await fetch('https://openrouter.ai/api/v1/models');
  const data = await res.json();
  return data.data;
}

// 依條件尋找模型
async function findModels(filter: {
  author?: string;      // 例如 'anthropic', 'openai', 'google'
  minContext?: number;  // 例如 100000 表示 10 萬上下文
  maxPromptPrice?: number; // 例如 0.001 表示便宜模型
}): Promise<OpenRouterModel[]> {
  const models = await fetchModels();

  return models.filter((m) => {
    if (filter.author && !m.id.startsWith(filter.author + '/')) return false;
    if (filter.minContext && m.context_length < filter.minContext) return false;
    if (filter.maxPromptPrice) {
      const price = parseFloat(m.pricing.prompt);
      if (price > filter.maxPromptPrice) return false;
    }
    return true;
  });
}

// 範例:取得最新的 Claude 模型
const claudeModels = await findModels({ author: 'anthropic' });
console.log(claudeModels.map((m) => m.id));

// 範例:取得 10 萬以上上下文的模型
const longContextModels = await findModels({ minContext: 100000 });

// 範例:取得便宜模型
const cheapModels = await findModels({ maxPromptPrice: 0.0005 });

在代理中動態選擇模型

// 使用動態模型選擇建立代理
const models = await fetchModels();
const bestModel = models.find((m) => m.id.includes('claude')) || models[0];

const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: bestModel.id,  // 使用探索到的模型
  instructions: 'You are a helpful assistant.',
});

使用 openrouter/auto

為求簡便,可使用 openrouter/auto,它會自動為你的請求選擇最佳可用模型:

const agent = createAgent({
  apiKey: process.env.OPENROUTER_API_KEY!,
  model: 'openrouter/auto',  // 自動選擇最佳模型
});

模型 API 參考

資源