- 刪除舊版 clawbot.py (已有新版 openclaw.py) - 更新 models/ai.py 類型定義 (ClawBotAnalysisRequest/Response) - 更新 api/v1/ai.py import 與註解 - 更新 Discord username - 更新所有註解與文檔 依據: feedback_openclaw_naming.md (統帥 2026-03-20 正式命名決議) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
185 lines
6.5 KiB
Python
185 lines
6.5 KiB
Python
"""
|
||
Agent (OpenClaw) Endpoints
|
||
ADR-005: BFF 架構 - 所有 AI 調用經過 BFF
|
||
Phase 1.2: 真實 Ollama 串接
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Literal
|
||
from uuid import UUID, uuid4
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Query
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ==================== Ollama Config ====================
|
||
OLLAMA_BASE_URL = "http://192.168.0.188:11434"
|
||
OLLAMA_MODEL = "llama3.2:latest" # 可根據實際部署調整
|
||
OLLAMA_TIMEOUT = 120.0 # 串流超時
|
||
|
||
|
||
class ChatRequest(BaseModel):
|
||
message: str
|
||
conversation_id: UUID | None = None
|
||
context: dict | None = None
|
||
|
||
|
||
class SuggestedAction(BaseModel):
|
||
id: str
|
||
label: str
|
||
description: str | None = None
|
||
risk_level: Literal["low", "medium", "high", "critical"]
|
||
|
||
|
||
class ChatResponse(BaseModel):
|
||
message: str
|
||
conversation_id: UUID
|
||
actions: list[SuggestedAction] | None = None
|
||
requires_approval: bool = False
|
||
approval_id: UUID | None = None
|
||
|
||
|
||
class AgentStatus(BaseModel):
|
||
status: Literal["idle", "thinking", "executing", "waiting_approval"]
|
||
active_conversations: int
|
||
current_task: str | None = None
|
||
last_activity: datetime | None = None
|
||
|
||
|
||
@router.post("/chat", response_model=ChatResponse)
|
||
async def chat_with_agent(request: ChatRequest) -> ChatResponse:
|
||
"""與 OpenClaw 對話"""
|
||
conversation_id = request.conversation_id or uuid4()
|
||
|
||
# TODO: 實際調用 OpenClaw
|
||
return ChatResponse(
|
||
message=f"收到訊息: {request.message}",
|
||
conversation_id=conversation_id,
|
||
requires_approval=False,
|
||
)
|
||
|
||
|
||
@router.post("/chat/stream")
|
||
async def chat_with_agent_stream(request: ChatRequest) -> StreamingResponse:
|
||
"""與 OpenClaw 對話 (SSE 串流)"""
|
||
|
||
async def generate():
|
||
# TODO: 實際串流
|
||
yield "data: Hello from OpenClaw\n\n"
|
||
yield "data: [DONE]\n\n"
|
||
|
||
return StreamingResponse(
|
||
generate(),
|
||
media_type="text/event-stream",
|
||
)
|
||
|
||
|
||
@router.get("/status", response_model=AgentStatus)
|
||
async def get_agent_status() -> AgentStatus:
|
||
"""OpenClaw 狀態"""
|
||
return AgentStatus(
|
||
status="idle",
|
||
active_conversations=0,
|
||
current_task=None,
|
||
last_activity=datetime.utcnow(),
|
||
)
|
||
|
||
|
||
@router.get("/thinking")
|
||
async def get_agent_thinking(
|
||
prompt: str = Query(
|
||
default="你是 AWOOOI 智能運維助手。請簡短分析一下目前系統的健康狀態,用中文回答。",
|
||
description="發送給 AI 的提示詞",
|
||
),
|
||
model: str = Query(default=OLLAMA_MODEL, description="Ollama 模型名稱"),
|
||
) -> StreamingResponse:
|
||
"""
|
||
OpenClaw 思考軌跡 (SSE 串流)
|
||
Phase 1.2: 真實串接 Ollama at 192.168.0.188:11434
|
||
"""
|
||
|
||
async def generate_thinking_stream():
|
||
"""串接 Ollama 並轉換為 SSE 格式"""
|
||
# 1. 開始思考
|
||
yield f"data: {json.dumps({'type': 'thinking', 'content': '正在連接 AI 模型...'}, ensure_ascii=False)}\n\n"
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=OLLAMA_TIMEOUT) as client:
|
||
# 2. 發送請求到 Ollama
|
||
yield f"data: {json.dumps({'type': 'thinking', 'content': f'模型: {model}'}, ensure_ascii=False)}\n\n"
|
||
|
||
async with client.stream(
|
||
"POST",
|
||
f"{OLLAMA_BASE_URL}/api/generate",
|
||
json={
|
||
"model": model,
|
||
"prompt": prompt,
|
||
"stream": True,
|
||
},
|
||
) as response:
|
||
if response.status_code != 200:
|
||
yield f"data: {json.dumps({'type': 'error', 'content': f'Ollama 錯誤: HTTP {response.status_code}'}, ensure_ascii=False)}\n\n"
|
||
yield "data: [DONE]\n\n"
|
||
return
|
||
|
||
yield f"data: {json.dumps({'type': 'thinking', 'content': '開始接收 AI 回應...'}, ensure_ascii=False)}\n\n"
|
||
|
||
# 3. 串流讀取 Ollama 回應
|
||
buffer = ""
|
||
async for line in response.aiter_lines():
|
||
if not line:
|
||
continue
|
||
|
||
try:
|
||
chunk = json.loads(line)
|
||
token = chunk.get("response", "")
|
||
done = chunk.get("done", False)
|
||
|
||
if token:
|
||
# 累積 token,每 10 字符或遇到標點符號時發送
|
||
buffer += token
|
||
if len(buffer) >= 10 or any(p in buffer for p in "。!?,、\n"):
|
||
yield f"data: {json.dumps({'type': 'thinking', 'content': buffer}, ensure_ascii=False)}\n\n"
|
||
buffer = ""
|
||
|
||
if done:
|
||
# 發送剩餘 buffer
|
||
if buffer:
|
||
yield f"data: {json.dumps({'type': 'thinking', 'content': buffer}, ensure_ascii=False)}\n\n"
|
||
# 發送完成訊息
|
||
yield f"data: {json.dumps({'type': 'result', 'content': '分析完成'}, ensure_ascii=False)}\n\n"
|
||
break
|
||
|
||
except json.JSONDecodeError as e:
|
||
logger.warning(f"JSON 解析失敗: {line[:100]}... - {e}")
|
||
continue
|
||
|
||
except httpx.ConnectError as e:
|
||
logger.error(f"無法連接 Ollama: {e}")
|
||
yield f"data: {json.dumps({'type': 'error', 'content': f'無法連接 Ollama ({OLLAMA_BASE_URL})'}, ensure_ascii=False)}\n\n"
|
||
except httpx.TimeoutException as e:
|
||
logger.error(f"Ollama 超時: {e}")
|
||
yield f"data: {json.dumps({'type': 'error', 'content': '請求超時'}, ensure_ascii=False)}\n\n"
|
||
except Exception as e:
|
||
logger.error(f"未知錯誤: {e}")
|
||
yield f"data: {json.dumps({'type': 'error', 'content': f'未知錯誤: {str(e)}'}, ensure_ascii=False)}\n\n"
|
||
|
||
# 4. 結束標記
|
||
yield "data: [DONE]\n\n"
|
||
|
||
return StreamingResponse(
|
||
generate_thinking_stream(),
|
||
media_type="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
"X-Accel-Buffering": "no", # 禁用 Nginx 緩衝
|
||
},
|
||
)
|