概要
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_completion(plugins/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
ストリームペイロードの構造
- コンテキストチャンク: メタデータ(トピック ID、ボットユーザー ID、エージェント ID)を提供します。
- 部分チャンク: ストリーミングされたテキストの断片を含みます。
ストリームの例:
{"topic_id":42,"bot_user_id":7,"agent_id":123}
{"partial":"Hello"}
{"partial":" there"}
クライアントは partial フィールドを連結して最終的な回答を構築する必要があります。
ワークフローと副作用
このエンドポイントは実行中に以下のアクションを実行します。
- 生の
queryを含むユーザー投稿を作成します。 - チャンク化された応答を介して AI の回答をストリーミングします。
- 累積された回答を含む最終的な AI 返信投稿を作成します。
- 新しい 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_idとpreferred_usernameを新しい希望のユーザー名として渡し、usernameフィールドをスキップしてください。 - 既存のユーザーを使用して会話したい場合は、そのユーザーの
usernameを渡し、unique_user_idとpreferred_usernameフィールドをスキップしてください。