Discourse API経由でAIボットを使用する

概要

Discourse AI は、AI エージェントの応答を生のチャンク化 HTTP 応答としてストリーミングするための管理/API エンドポイントを公開しています。

  • プロトコル: 生のチャンク化 HTTP 転送エンコーディング(Server-Sent Events ではありません)。
  • 実装: Rack ソケットをハイジャックして、改行区切りの JSON オブジェクトをストリーミングします。
  • 副作用: これは単なる補完 API ではなく、実際の Discourse プライベートメッセージ(PM)投稿を作成します。

エンドポイントの詳細

  • 場所: plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_agents_controller.rb:175-272
  • ルート: POST /admin/plugins/discourse-ai/ai-agents/stream-reply.json
  • API キーのスコープ: ai:stream_completionplugins/discourse-ai/lib/ai_bot/entry_point.rb:283-286 に登録)

リクエストヘッダー

POST /admin/plugins/discourse-ai/ai-agents/stream-reply.json
Api-Key: <your_api_key>
Api-Username: <your_username>
Content-Type: application/json

リクエストボディのパラメータ

パラメータ 必須 説明
agent_id Integer 任意* エージェントの識別子。
agent_name String 任意* エージェントの代替識別子。
query String 必須 ユーザーのプロンプト/質問。
username String user_unique_id が省略されている場合必須 チャット PM を既存のユーザーに関連付ける必要がある場合に使用します。
user_unique_id String username が省略されている場合必須 最終ユーザーを識別します。カスタムフィールド ai-stream-conversation-unique-id をキーとするステージングユーザーを作成/再利用します。
preferred_username String 任意* ユーザーのユーザー名(user_unique_id を使用しない場合)。
topic_id Integer 任意 既存の PM 会話を継続します。
custom_instructions String 任意 エージェントのプロンプトコンテキストに追加されます。

注: username(既存の Discourse ユーザー)または user_unique_id のいずれかで最終ユーザーを識別する必要があります。


応答形式

サーバーは Transfer-Encoding: chunked を伴う 200 OK 応答を返します。ストリームは改行区切りの JSON オブジェクトで構成されます。

HTTP ヘッダー

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked
Cache-Control: no-cache, no-store, must-revalidate
Connection: close
X-Accel-Buffering: no
X-Content-Type-Options: nosniff

ストリームペイロードの構造

  1. コンテキストチャンク: メタデータ(トピック ID、ボットユーザー ID、エージェント ID)を提供します。
  2. 部分チャンク: ストリーミングされたテキストの断片を含みます。

ストリームの例:

{"topic_id":42,"bot_user_id":7,"agent_id":123}

{"partial":"Hello"}

{"partial":" there"}

クライアントは partial フィールドを連結して最終的な回答を構築する必要があります。


ワークフローと副作用

このエンドポイントは実行中に以下のアクションを実行します。

  1. 生の query を含むユーザー投稿を作成します。
  2. チャンク化された応答を介して AI の回答をストリーミングします。
  3. 累積された回答を含む最終的な AI 返信投稿を作成します。
  4. 新しい PM の場合、トピックに自動タイトルを付ける可能性があります。

カスタムクライアント実行ツール

ツール定義を提供することで、モデルが外部ツールを呼び出すことを許可できます。ツールが呼び出されると、サーバーはストリームを一時停止し、クライアントがツールを実行して再開できるようにします。

1. ツールを伴う初期リクエスト

リクエストボディに custom_tools を含めます。

{
  "agent_id": 123,
  "query": "What's the weather?",
  "user_unique_id": "external-user-42",
  "custom_tools": [
    {
      "name": "client_weather",
      "description": "Gets weather from the client runtime",
      "parameters": [
        {
          "name": "city",
          "description": "City to fetch weather for",
          "type": "string",
          "required": true
        }
      ]
    }
  ]
}

2. ツール呼び出しイベント

モデルがツールを呼び出すと、ストリームは tool_calls イベントを発行して停止します。

{
  "event": "tool_calls",
  "tool_calls": [
    {
      "id": "tool_1",
      "name": "client_weather",
      "parameters": { "city": "Austin" }
    }
  ],
  "resume_token": "..."
}

この時点で、サーバーは会話状態を Redis に永続化します。

3. ツール結果を伴う再開

クライアントはツールを実行し、ストリームを再開します。

{
  "resume_token": "...",
  "tool_results": [
    {
      "tool_call_id": "tool_1",
      "content": { "temperature_c": 23 }
    }
  ]
}

サーバーは保存されたプロンプト状態を読み込み、ツール結果を挿入し、生成を継続して、さらに多くの partial チャンクをストリーミングします。

ツールの制限

  • カスタムツールの最大数: 20
  • ツール結果の最大数: 20
  • カスタムツール定義の最大サイズ: 10,000 バイト
  • ツール結果コンテンツの最大サイズ: 100 KB
  • 再開 TTL: 15 分
  • 再開ラウンドの最大数: 10

実装の参照

  • コントローラー: plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_agents_controller.rb
  • ストリーマー: plugins/discourse-ai/lib/ai_bot/response_http_streamer.rb
  • カスタムツールセッション: plugins/discourse-ai/lib/ai_bot/stream_reply_custom_tools_session.rb

テスト例

包括的な例については、plugins/discourse-ai/spec/requests/admin/ai_agents_controller_spec.rb を参照してください。

  • 新しいストリーミング会話: 1248-1356 行
  • カスタムツール + 再開トークン: 1358-1448 行
  • 並列ツール呼び出し: 1467-1590 行

サンプル実装

Ruby スクリプト
require 'net/http'
require 'json'
require 'uri'

# 設定
DISCOURSE_URL = '<your site URL>'
API_KEY = '<your API key>'
USERNAME = '<your username>'
AGENT_ID = -1 # または agent_name を使用
QUERY = "Hello, how are you today?"
USER_UNIQUE_ID ='<PM を USERNAME ユーザーに送信したい場合は空にしてください>'


# URI を作成するヘルパー
uri = URI("#{DISCOURSE_URL}/admin/plugins/discourse-ai/ai-agents/stream-reply.json")

# HTTP リクエストを作成
request = Net::HTTP::Post.new(uri)
request['Api-Key'] = API_KEY
request['Api-Username'] = USERNAME
request['Content-Type'] = 'application/json'

# リクエストボディを準備
body = {
  agent_id: AGENT_ID,
  query: QUERY,
  ## 会話に既存のユーザーを使用したい場合は、以下の行をコメントアウトしてください。また、`username` は渡された場合、`user_unique_id` より優先されます。
  # username: USERNAME,
  ## 新しいステージングユーザーを作成するには、以下のフィールドを `user_unique_id` と一緒に使用してください。これを使用する場合、`username` の渡しをスキップしてください。
  # preferred_username: USER_UNIQUE_ID
}
body[:user_unique_id] = USER_UNIQUE_ID unless USER_UNIQUE_ID.empty?

body = body.to_json
request.body = body

http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = (uri.scheme == 'https')

http.request(request) do |response|
    # 応答が成功したか確認
    if response.code == '200'
    puts "Stream started successfully."
    puts "Response headers: #{response.to_hash}"
    puts "Streaming content:"   
    
    # チャンク化された応答を読み取る
    response.read_body do |chunk|
        # 応答は改行区切りの JSON オブジェクトです
        chunk.each_line do |line|
        line = line.strip
        next if line.empty?
        
        begin
            json = JSON.parse(line)
            
            if json['topic_id']
            puts "\n--- Context Received ---"
            puts "Topic ID: #{json['topic_id']}"
            puts "Bot User ID: #{json['bot_user_id']}"
            puts "Agent ID: #{json['agent_id']}"
            elsif json['partial']
            # 部分コンテンツをストリーミング
            print json['partial']
            elsif json['event'] == 'tool_calls'
            puts "\n--- Tool Call Received ---"
            puts JSON.pretty_generate(json)
            # ここではツール実行と再開を処理します
            # この例では、単に出力するだけです
            end

            # puts "<new word>"
        rescue JSON::ParserError => e
            puts "\nError parsing JSON: #{e.message}"
            puts "Raw line: #{line}"
        end
        end
    end
    puts "\n--- Stream Finished ---"

    else
    puts "Error: #{response.code} #{response.message}"
    puts "Response body: #{response.body}"
    end
end

注意事項

  • 会話を新しいステージングユーザーの名義で行いたい場合は、unique_user_idpreferred_username を新しい希望のユーザー名として渡し、username フィールドをスキップしてください。
  • 既存のユーザーを使用して会話したい場合は、そのユーザーの username を渡し、unique_user_idpreferred_username フィールドをスキップしてください。
「いいね!」 2