AB#5308: Working picoclaw webhook
This commit is contained in:
parent
e2975116a2
commit
e7ba999cc7
13 changed files with 1450 additions and 77 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -68,5 +68,6 @@ web/backend/dist/*
|
||||||
|
|
||||||
docker/data/
|
docker/data/
|
||||||
docker/data_gateway/
|
docker/data_gateway/
|
||||||
|
docker/vpn/
|
||||||
|
|
||||||
.omc/
|
.omc/
|
||||||
|
|
|
||||||
|
|
@ -1525,36 +1525,74 @@ paths:
|
||||||
/api/webhook/process:
|
/api/webhook/process:
|
||||||
post:
|
post:
|
||||||
tags: [webhook]
|
tags: [webhook]
|
||||||
summary: Submit an asynchronous processing job with webhook callback
|
summary: Submit an asynchronous AI processing job with streaming webhook callbacks
|
||||||
description: |
|
description: |
|
||||||
Accepts a processing job and returns immediately with a job ID (202 Accepted).
|
Accepts a processing job and returns immediately with a job ID (202 Accepted).
|
||||||
The job runs in the background, and results are POSTed to the provided webhook URL
|
The job runs in the background with PicoClaw AI, and sends multiple streaming
|
||||||
when processing completes.
|
callbacks to your webhook URL as the AI generates its response.
|
||||||
|
|
||||||
**Processing flow:**
|
**Processing flow:**
|
||||||
1. Submit job → receive job_id
|
1. Submit job with `prompt` in payload → receive job_id and session_id
|
||||||
2. Backend processes asynchronously
|
2. Backend connects to PicoClaw AI via WebSocket
|
||||||
3. Result POSTed to webhook_url
|
3. AI streams response in chunks
|
||||||
|
4. Each chunk triggers a webhook callback (status: "streaming")
|
||||||
|
5. Final completion callback sent (status: "completed")
|
||||||
|
|
||||||
**Webhook callback payload (success):**
|
**Session Management:**
|
||||||
|
- Provide `session_id` to maintain conversation context across requests
|
||||||
|
- If `session_id` provided, any existing active connection for that session is cancelled
|
||||||
|
- If omitted, a new session_id is generated automatically
|
||||||
|
|
||||||
|
**Webhook Streaming Callback (per chunk):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"job_id": "uuid",
|
"job_id": "uuid",
|
||||||
|
"session_id": "uuid",
|
||||||
|
"status": "streaming",
|
||||||
|
"message": "AI response chunk",
|
||||||
|
"accumulated_length": 150,
|
||||||
|
"message_count": 3,
|
||||||
|
"is_complete": false,
|
||||||
|
"timestamp": "2026-04-22T05:00:01Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Webhook Completion Callback:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "uuid",
|
||||||
|
"session_id": "uuid",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"result": { "processed_data": "..." },
|
"result": {
|
||||||
"timestamp": "2026-04-17T10:00:05Z"
|
"message_count": 10,
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"is_complete": true,
|
||||||
|
"message_count": 10,
|
||||||
|
"timestamp": "2026-04-22T05:03:01Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Webhook callback payload (error):**
|
**Webhook Error Callback:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"job_id": "uuid",
|
"job_id": "uuid",
|
||||||
|
"session_id": "uuid",
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": "error message",
|
"error": "AI processing failed: connection timeout",
|
||||||
"timestamp": "2026-04-17T10:00:05Z"
|
"is_complete": true,
|
||||||
|
"message_count": 0,
|
||||||
|
"timestamp": "2026-04-22T05:00:30Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Completion Detection:**
|
||||||
|
- After 3 minutes of idle time (no new messages from AI)
|
||||||
|
- AI explicitly signals completion
|
||||||
|
- WebSocket connection closes
|
||||||
|
- Error occurs
|
||||||
|
|
||||||
|
See schemas: WebhookStreamingCallback, WebhookCompletionCallback, WebhookErrorCallback
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|
@ -2223,15 +2261,19 @@ components:
|
||||||
webhook_url:
|
webhook_url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
description: URL where results will be POSTed when processing completes
|
description: URL where streaming callbacks and completion results will be POSTed
|
||||||
example: "https://your-app.com/webhook/callback"
|
example: "https://your-app.com/webhook/callback"
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Optional session ID for conversation context. If provided, maintains context across requests. Cancels any existing active connection for this session.
|
||||||
|
example: "1e052a24-bd5e-40a9-b418-8824e6d511ba"
|
||||||
payload:
|
payload:
|
||||||
type: object
|
type: object
|
||||||
description: Arbitrary JSON payload to be processed
|
description: Processing payload. Use "prompt" field for AI processing.
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
example:
|
example:
|
||||||
data: "your data here"
|
prompt: "What is Python?"
|
||||||
priority: "high"
|
|
||||||
|
|
||||||
WebhookProcessResponse:
|
WebhookProcessResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -2241,6 +2283,11 @@ components:
|
||||||
format: uuid
|
format: uuid
|
||||||
description: Unique identifier for this job
|
description: Unique identifier for this job
|
||||||
example: 550e8400-e29b-41d4-a716-446655440000
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Session ID for conversation context
|
||||||
|
example: "1e052a24-bd5e-40a9-b418-8824e6d511ba"
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [processing]
|
enum: [processing]
|
||||||
|
|
@ -2284,3 +2331,126 @@ components:
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Job completion timestamp (null if still processing)
|
description: Job completion timestamp (null if still processing)
|
||||||
example: "2026-04-17T10:00:05Z"
|
example: "2026-04-17T10:00:05Z"
|
||||||
|
|
||||||
|
WebhookStreamingCallback:
|
||||||
|
type: object
|
||||||
|
description: Callback POSTed to webhook_url for each message chunk during streaming
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Job identifier
|
||||||
|
example: "f6d476c7-4d2f-4956-96b2-9137138d32e7"
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Session ID for conversation context
|
||||||
|
example: "1e052a24-bd5e-40a9-b418-8824e6d511ba"
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [streaming]
|
||||||
|
description: Status is "streaming" for intermediate chunks
|
||||||
|
example: streaming
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: The message content chunk
|
||||||
|
example: "Python is a high-level programming language"
|
||||||
|
accumulated_length:
|
||||||
|
type: integer
|
||||||
|
description: Total length of all messages received so far
|
||||||
|
example: 150
|
||||||
|
message_count:
|
||||||
|
type: integer
|
||||||
|
description: Number of message chunks received so far
|
||||||
|
example: 3
|
||||||
|
is_complete:
|
||||||
|
type: boolean
|
||||||
|
description: Always false for streaming callbacks
|
||||||
|
example: false
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Callback timestamp
|
||||||
|
example: "2026-04-22T05:00:01Z"
|
||||||
|
|
||||||
|
WebhookCompletionCallback:
|
||||||
|
type: object
|
||||||
|
description: Final callback POSTed to webhook_url when processing completes
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Job identifier
|
||||||
|
example: "f6d476c7-4d2f-4956-96b2-9137138d32e7"
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Session ID for conversation context
|
||||||
|
example: "1e052a24-bd5e-40a9-b418-8824e6d511ba"
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [completed]
|
||||||
|
description: Status is "completed" on success
|
||||||
|
example: completed
|
||||||
|
result:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message_count:
|
||||||
|
type: integer
|
||||||
|
description: Total number of message chunks sent
|
||||||
|
example: 10
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Always null on success
|
||||||
|
example: null
|
||||||
|
is_complete:
|
||||||
|
type: boolean
|
||||||
|
description: Always true for completion callback
|
||||||
|
example: true
|
||||||
|
message_count:
|
||||||
|
type: integer
|
||||||
|
description: Total chunks sent
|
||||||
|
example: 10
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Completion timestamp
|
||||||
|
example: "2026-04-22T05:03:01Z"
|
||||||
|
|
||||||
|
WebhookErrorCallback:
|
||||||
|
type: object
|
||||||
|
description: Callback POSTed to webhook_url when processing fails
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Job identifier
|
||||||
|
example: "f6d476c7-4d2f-4956-96b2-9137138d32e7"
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Session ID for conversation context
|
||||||
|
example: "1e052a24-bd5e-40a9-b418-8824e6d511ba"
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [failed]
|
||||||
|
description: Status is "failed" when error occurs
|
||||||
|
example: failed
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: Error message describing what went wrong
|
||||||
|
example: "AI processing failed: connection timeout"
|
||||||
|
is_complete:
|
||||||
|
type: boolean
|
||||||
|
description: Always true for error callback
|
||||||
|
example: true
|
||||||
|
message_count:
|
||||||
|
type: integer
|
||||||
|
description: Number of chunks sent before error
|
||||||
|
example: 0
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Error timestamp
|
||||||
|
example: "2026-04-22T05:00:30Z"
|
||||||
|
|
|
||||||
|
|
@ -707,7 +707,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Webhook",
|
"name": "Webhook",
|
||||||
"description": "Asynchronous webhook processing. Submit jobs that run in the background and POST results to your webhook URL.",
|
"description": "Asynchronous AI processing with streaming webhook callbacks. Submit jobs with prompts and receive multiple callbacks as AI generates responses. Supports session management for conversation context.",
|
||||||
"item": [
|
"item": [
|
||||||
{
|
{
|
||||||
"name": "Submit Processing Job",
|
"name": "Submit Processing Job",
|
||||||
|
|
@ -734,10 +734,10 @@
|
||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"webhook_url\": \"https://webhook.site/your-unique-id\",\n \"payload\": {\n \"data\": \"your data here\",\n \"priority\": \"high\",\n \"timestamp\": \"{{$isoTimestamp}}\"\n }\n}",
|
"raw": "{\n \"webhook_url\": \"https://webhook.site/your-unique-id\",\n \"session_id\": \"optional-session-for-context\",\n \"payload\": {\n \"prompt\": \"What is Python?\"\n }\n}",
|
||||||
"options": { "raw": { "language": "json" } }
|
"options": { "raw": { "language": "json" } }
|
||||||
},
|
},
|
||||||
"description": "Submit an async job. Returns 202 Accepted with job_id. Job runs in background and results are POSTed to webhook_url.\n\nTest with webhook.site:\n1. Visit https://webhook.site\n2. Copy your unique URL\n3. Replace webhook_url above\n4. Send request\n5. Watch callback arrive at webhook.site\n\nResponse: {job_id, status: \"processing\", timestamp}\n\nWebhook receives: {job_id, status: \"completed\"|\"failed\", result: {...}|error: \"...\", timestamp}"
|
"description": "Submit an AI processing job with streaming callbacks.\n\nTest with webhook.site:\n1. Visit https://webhook.site\n2. Copy your unique URL\n3. Replace webhook_url above\n4. Send request\n5. Watch MULTIPLE callbacks arrive (streaming + completion)\n\nImmediate Response: {job_id, session_id, status: \"processing\", timestamp}\n\nWebhook Streaming Callbacks (multiple):\n{\n job_id, session_id, status: \"streaming\",\n message: \"chunk\", accumulated_length, message_count,\n is_complete: false, timestamp\n}\n\nWebhook Completion Callback:\n{\n job_id, session_id, status: \"completed\",\n result: {message_count, error: null},\n is_complete: true, timestamp\n}\n\nSession Management:\n- Provide same session_id for conversation context\n- Previous active connection cancelled automatically\n- Omit session_id for new conversation"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -760,7 +760,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Submit Job - Example 1 (Simple)",
|
"name": "Submit Job - Example 1 (Simple AI Query)",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"url": "{{base_url}}/api/webhook/process",
|
"url": "{{base_url}}/api/webhook/process",
|
||||||
|
|
@ -769,10 +769,10 @@
|
||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"webhook_url\": \"https://webhook.site/test\",\n \"payload\": {\n \"message\": \"Hello, World!\"\n }\n}",
|
"raw": "{\n \"webhook_url\": \"https://webhook.site/test\",\n \"payload\": {\n \"prompt\": \"What is 2+2?\"\n }\n}",
|
||||||
"options": { "raw": { "language": "json" } }
|
"options": { "raw": { "language": "json" } }
|
||||||
},
|
},
|
||||||
"description": "Simple example with minimal payload."
|
"description": "Simple AI query. AI will respond and webhook receives streaming chunks."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
245
docs/pico-protocol-completion.md
Normal file
245
docs/pico-protocol-completion.md
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
# Pico Protocol Completion Signal
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Pico Protocol now includes an explicit completion marker that indicates when the AI has finished responding to a message. This eliminates the need for long timeout-based completion detection.
|
||||||
|
|
||||||
|
## Completion Marker
|
||||||
|
|
||||||
|
### `==!== process_end ==!==`
|
||||||
|
|
||||||
|
When the AI finishes processing, PicoClaw sends a special marker message as the last `message.create`:
|
||||||
|
|
||||||
|
**Message Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message.create",
|
||||||
|
"session_id": "session-uuid",
|
||||||
|
"timestamp": 1234567890,
|
||||||
|
"payload": {
|
||||||
|
"content": "==!== process_end ==!==",
|
||||||
|
"thought": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This marker is:
|
||||||
|
- ✅ Sent as a regular message (compatible with existing protocol)
|
||||||
|
- ✅ Always the last message in a response
|
||||||
|
- ✅ Easy to detect by clients
|
||||||
|
- ✅ Backward compatible (old clients just ignore it)
|
||||||
|
|
||||||
|
## Message Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server (Gateway)
|
||||||
|
| |
|
||||||
|
| message.send |
|
||||||
|
| {"prompt": "Hello"} |
|
||||||
|
| ---------------------------------> |
|
||||||
|
| |
|
||||||
|
| typing.start |
|
||||||
|
| <--------------------------------- |
|
||||||
|
| |
|
||||||
|
| message.create (chunk 1) |
|
||||||
|
| "Hello" |
|
||||||
|
| <--------------------------------- |
|
||||||
|
| |
|
||||||
|
| message.create (chunk 2) |
|
||||||
|
| "there!" |
|
||||||
|
| <--------------------------------- |
|
||||||
|
| |
|
||||||
|
| typing.stop |
|
||||||
|
| <--------------------------------- |
|
||||||
|
| |
|
||||||
|
| message.create (marker) ⭐ |
|
||||||
|
| "==!== process_end ==!==" |
|
||||||
|
| <--------------------------------- |
|
||||||
|
| |
|
||||||
|
```
|
||||||
|
|
||||||
|
## When is the Marker Sent?
|
||||||
|
|
||||||
|
The completion marker is sent when:
|
||||||
|
|
||||||
|
1. ✅ **AI finishes generating response** - After the last content chunk
|
||||||
|
2. ✅ **Typing indicator stops** - Sent immediately after `typing.stop`
|
||||||
|
3. ✅ **Before connection would idle** - No need to wait for timeout
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### Before (Timeout-Based)
|
||||||
|
- ❌ Had to wait 1 minute of idle time to detect completion
|
||||||
|
- ❌ Slow response time for short messages
|
||||||
|
- ❌ Risk of premature timeout for long-thinking AI
|
||||||
|
- ❌ Wasted resources keeping connection open
|
||||||
|
|
||||||
|
### After (Signal-Based)
|
||||||
|
- ✅ **Instant completion detection** - No waiting for timeout
|
||||||
|
- ✅ **Fast for short messages** - Completes in ~2 seconds instead of 60+
|
||||||
|
- ✅ **Reliable for long messages** - No risk of timeout
|
||||||
|
- ✅ **Efficient resource usage** - Connection closes immediately
|
||||||
|
|
||||||
|
## Webhook Processing Impact
|
||||||
|
|
||||||
|
### Completion Detection Order
|
||||||
|
|
||||||
|
The webhook processor now detects completion via:
|
||||||
|
|
||||||
|
1. **Completion marker `==!== process_end ==!==`** ⭐ (Primary - instant)
|
||||||
|
2. Idle timeout (3 minutes - fallback)
|
||||||
|
3. WebSocket close
|
||||||
|
4. Error message
|
||||||
|
5. Context timeout (5 minutes)
|
||||||
|
|
||||||
|
### Timing Improvement
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```
|
||||||
|
Message received → Wait 60 seconds → Send final callback
|
||||||
|
Total: 60+ seconds after last message
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```
|
||||||
|
Message received → message.complete → Send final callback
|
||||||
|
Total: ~2 seconds after last message
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Server-Side (Gateway)
|
||||||
|
|
||||||
|
Modified `/pkg/channels/pico/pico.go`:
|
||||||
|
```go
|
||||||
|
func (c *PicoChannel) StartTyping(...) (func(), error) {
|
||||||
|
startMsg := newMessage(TypeTypingStart, nil)
|
||||||
|
c.broadcastToSession(chatID, startMsg)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
stopMsg := newMessage(TypeTypingStop, nil)
|
||||||
|
c.broadcastToSession(chatID, stopMsg)
|
||||||
|
|
||||||
|
// Send completion marker as a regular message
|
||||||
|
markerMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||||
|
PayloadKeyContent: "==!== process_end ==!==",
|
||||||
|
PayloadKeyThought: false,
|
||||||
|
})
|
||||||
|
c.broadcastToSession(chatID, markerMsg)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client-Side (Webhook Processor)
|
||||||
|
|
||||||
|
Detects the completion marker:
|
||||||
|
```go
|
||||||
|
case "message.create":
|
||||||
|
if payload, ok := msg["payload"].(map[string]interface{}); ok {
|
||||||
|
if content, ok := payload["content"].(string); ok && content != "" {
|
||||||
|
// Check for completion marker
|
||||||
|
if content == "==!== process_end ==!==" {
|
||||||
|
logger.InfoC("webhook", "Received completion marker")
|
||||||
|
sendStreamingWebhook(..., true, ...) // Final callback
|
||||||
|
conn.Close()
|
||||||
|
return fullResponse, messageCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular content - send as streaming chunk
|
||||||
|
fullResponse += content
|
||||||
|
sendStreamingWebhook(..., false, ...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
✅ **Fully backward compatible**
|
||||||
|
|
||||||
|
- Old clients ignore the `message.complete` message (unknown type)
|
||||||
|
- Old clients continue using timeout-based detection
|
||||||
|
- New clients get instant completion via the signal
|
||||||
|
- Timeout fallback still works if signal is missed
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Completion Signal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Send a prompt
|
||||||
|
curl -X POST http://localhost:18800/api/webhook/process \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"webhook_url": "https://webhook.site/your-id",
|
||||||
|
"session_id": "test-session",
|
||||||
|
"payload": {"prompt": "Say hello"}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 2. Watch webhook.site for callbacks
|
||||||
|
# You should see:
|
||||||
|
# - Multiple "streaming" callbacks (chunks)
|
||||||
|
# - Final "completed" callback within 2-3 seconds after last chunk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Timeline
|
||||||
|
|
||||||
|
```
|
||||||
|
0.0s: Request sent
|
||||||
|
0.5s: First chunk received → webhook callback
|
||||||
|
1.0s: Second chunk received → webhook callback
|
||||||
|
1.5s: Third chunk received → webhook callback
|
||||||
|
2.0s: message.complete received → final webhook callback ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare to old behavior:
|
||||||
|
```
|
||||||
|
0.0s: Request sent
|
||||||
|
0.5s: First chunk received → webhook callback
|
||||||
|
1.0s: Second chunk received → webhook callback
|
||||||
|
1.5s: Third chunk received → webhook callback
|
||||||
|
61.5s: Timeout detected → final webhook callback ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Log Messages
|
||||||
|
|
||||||
|
**When completion is detected via marker:**
|
||||||
|
```
|
||||||
|
[webhook] Received completion marker for job abc123: 5 messages, 150 chars
|
||||||
|
```
|
||||||
|
|
||||||
|
**When completion falls back to timeout:**
|
||||||
|
```
|
||||||
|
[webhook] Stream complete for job abc123: 5 messages, 150 chars (timeout)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
Track completion method:
|
||||||
|
- `completion_via_signal` - Fast path (desired)
|
||||||
|
- `completion_via_timeout` - Slow path (fallback)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Timeout Settings
|
||||||
|
|
||||||
|
Now that we have the completion marker, timeouts are just fallbacks:
|
||||||
|
|
||||||
|
```go
|
||||||
|
idleTimeout := 3 * time.Minute // Fallback if marker missed
|
||||||
|
maxWaitTime := 5 * time.Minute // Safety maximum
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Possible additions:
|
||||||
|
- `message.complete` with metadata (token count, finish_reason)
|
||||||
|
- Progress updates (`message.progress`)
|
||||||
|
- Cancellation support (`message.cancel`)
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Pico Protocol Overview](../pkg/channels/README.md)
|
||||||
|
- [Webhook Processing](webhook-ai-integration.md)
|
||||||
|
- [Session Management](webhook-processing.md)
|
||||||
|
|
@ -24,14 +24,15 @@ Background processor:
|
||||||
4. Collects AI response
|
4. Collects AI response
|
||||||
5. POSTs result to webhook_url
|
5. POSTs result to webhook_url
|
||||||
↓
|
↓
|
||||||
Your webhook receives:
|
Your webhook receives streaming callbacks:
|
||||||
{
|
{
|
||||||
"job_id": "uuid",
|
"job_id": "uuid",
|
||||||
"status": "completed",
|
"session_id": "uuid",
|
||||||
"result": {
|
"status": "streaming",
|
||||||
"data": "<AI response here>",
|
"message": "AI response chunk",
|
||||||
"error": null
|
"accumulated_length": 150,
|
||||||
},
|
"message_count": 3,
|
||||||
|
"is_complete": false,
|
||||||
"timestamp": "2026-04-17T10:00:05Z"
|
"timestamp": "2026-04-17T10:00:05Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
232
docs/webhook-callback-format.md
Normal file
232
docs/webhook-callback-format.md
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
# Webhook Callback Format
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When you submit a webhook processing request, your webhook endpoint will receive multiple callbacks as the AI streams its response.
|
||||||
|
|
||||||
|
## Callback Types
|
||||||
|
|
||||||
|
### 1. Streaming Callback (Per Message Chunk)
|
||||||
|
|
||||||
|
Sent for each message chunk as the AI generates the response.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "f6d476c7-4d2f-4956-96b2-9137138d32e7",
|
||||||
|
"session_id": "1e052a24-bd5e-40a9-b418-8824e6d511ba",
|
||||||
|
"status": "streaming",
|
||||||
|
"message": "This is part of the AI response",
|
||||||
|
"accumulated_length": 150,
|
||||||
|
"message_count": 3,
|
||||||
|
"is_complete": false,
|
||||||
|
"timestamp": "2026-04-22T05:00:01.123Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
- `job_id` - Unique identifier for this processing job
|
||||||
|
- `session_id` - Session ID for conversation context
|
||||||
|
- `status` - Always `"streaming"` for intermediate chunks
|
||||||
|
- `message` - The actual message content chunk
|
||||||
|
- `accumulated_length` - Total length of all messages received so far
|
||||||
|
- `message_count` - Number of message chunks received so far
|
||||||
|
- `is_complete` - Always `false` for streaming chunks
|
||||||
|
- `timestamp` - When this callback was sent
|
||||||
|
|
||||||
|
### 2. Completion Callback (Final)
|
||||||
|
|
||||||
|
Sent after 3 minutes of idle time or when processing completes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "f6d476c7-4d2f-4956-96b2-9137138d32e7",
|
||||||
|
"session_id": "1e052a24-bd5e-40a9-b418-8824e6d511ba",
|
||||||
|
"status": "completed",
|
||||||
|
"result": {
|
||||||
|
"message_count": 10,
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"is_complete": true,
|
||||||
|
"message_count": 10,
|
||||||
|
"timestamp": "2026-04-22T05:03:01.456Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
- `job_id` - Same job ID from streaming callbacks
|
||||||
|
- `session_id` - Same session ID
|
||||||
|
- `status` - `"completed"` when successful
|
||||||
|
- `result` - Summary information
|
||||||
|
- `message_count` - Total number of message chunks sent
|
||||||
|
- `error` - Always `null` on success
|
||||||
|
- `is_complete` - Always `true` for final callback
|
||||||
|
- `message_count` - Total chunks sent (same as `result.message_count`)
|
||||||
|
- `timestamp` - When completion was detected
|
||||||
|
|
||||||
|
**Note:** The full response text is NOT included in the completion callback. Your webhook should reconstruct it from the streaming `message` chunks.
|
||||||
|
|
||||||
|
### 3. Error Callback
|
||||||
|
|
||||||
|
Sent if processing fails.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "f6d476c7-4d2f-4956-96b2-9137138d32e7",
|
||||||
|
"session_id": "1e052a24-bd5e-40a9-b418-8824e6d511ba",
|
||||||
|
"status": "failed",
|
||||||
|
"error": "AI processing failed: connection timeout",
|
||||||
|
"is_complete": true,
|
||||||
|
"message_count": 0,
|
||||||
|
"timestamp": "2026-04-22T05:00:30.789Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
- `status` - `"failed"` when error occurs
|
||||||
|
- `error` - Description of what went wrong
|
||||||
|
- `is_complete` - Always `true` for error callbacks
|
||||||
|
- `message_count` - Number of chunks sent before error
|
||||||
|
|
||||||
|
## Full Example Flow
|
||||||
|
|
||||||
|
### Request
|
||||||
|
```bash
|
||||||
|
POST /api/webhook/process
|
||||||
|
{
|
||||||
|
"webhook_url": "https://your-app.com/webhook",
|
||||||
|
"session_id": "user-123",
|
||||||
|
"payload": {
|
||||||
|
"prompt": "What is Python?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (Immediate)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "abc-123",
|
||||||
|
"session_id": "user-123",
|
||||||
|
"status": "processing",
|
||||||
|
"timestamp": "2026-04-22T05:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Callbacks (Streamed)
|
||||||
|
|
||||||
|
**Callback 1 (streaming):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "abc-123",
|
||||||
|
"session_id": "user-123",
|
||||||
|
"status": "streaming",
|
||||||
|
"message": "Python is a high-level,",
|
||||||
|
"accumulated_length": 25,
|
||||||
|
"message_count": 1,
|
||||||
|
"is_complete": false,
|
||||||
|
"timestamp": "2026-04-22T05:00:02Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callback 2 (streaming):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "abc-123",
|
||||||
|
"session_id": "user-123",
|
||||||
|
"status": "streaming",
|
||||||
|
"message": " interpreted programming language",
|
||||||
|
"accumulated_length": 57,
|
||||||
|
"message_count": 2,
|
||||||
|
"is_complete": false,
|
||||||
|
"timestamp": "2026-04-22T05:00:03Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callback 3 (streaming):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "abc-123",
|
||||||
|
"session_id": "user-123",
|
||||||
|
"status": "streaming",
|
||||||
|
"message": " known for its simplicity.",
|
||||||
|
"accumulated_length": 83,
|
||||||
|
"message_count": 3,
|
||||||
|
"is_complete": false,
|
||||||
|
"timestamp": "2026-04-22T05:00:04Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callback 4 (completion):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "abc-123",
|
||||||
|
"session_id": "user-123",
|
||||||
|
"status": "completed",
|
||||||
|
"result": {
|
||||||
|
"message_count": 3,
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"is_complete": true,
|
||||||
|
"message_count": 3,
|
||||||
|
"timestamp": "2026-04-22T05:03:04Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reconstructing Full Response
|
||||||
|
|
||||||
|
Your webhook endpoint should collect all streaming messages:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Example webhook handler
|
||||||
|
responses = {} # job_id -> accumulated response
|
||||||
|
|
||||||
|
@app.post('/webhook')
|
||||||
|
def handle_webhook(data):
|
||||||
|
job_id = data['job_id']
|
||||||
|
|
||||||
|
if data['status'] == 'streaming':
|
||||||
|
# Accumulate message chunks
|
||||||
|
if job_id not in responses:
|
||||||
|
responses[job_id] = ""
|
||||||
|
responses[job_id] += data['message']
|
||||||
|
|
||||||
|
print(f"Received chunk: {data['message']}")
|
||||||
|
print(f"Total so far: {responses[job_id]}")
|
||||||
|
|
||||||
|
elif data['status'] == 'completed':
|
||||||
|
# Processing complete
|
||||||
|
full_response = responses.get(job_id, "")
|
||||||
|
print(f"Complete response: {full_response}")
|
||||||
|
|
||||||
|
# Process final response
|
||||||
|
process_final_response(full_response)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
del responses[job_id]
|
||||||
|
|
||||||
|
elif data['status'] == 'failed':
|
||||||
|
# Handle error
|
||||||
|
print(f"Error: {data['error']}")
|
||||||
|
|
||||||
|
return {'status': 'ok'}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
✅ **Multiple Callbacks** - Expect multiple webhooks per job
|
||||||
|
✅ **Message Field** - Streaming chunks use `message` key (not `chunk`)
|
||||||
|
✅ **Accumulation** - Collect all `message` values to get full response
|
||||||
|
✅ **Completion Detection** - Look for `is_complete: true` and `status: "completed"`
|
||||||
|
✅ **Session ID** - Use same session_id for conversation context
|
||||||
|
✅ **Order Guaranteed** - Messages arrive in order sent
|
||||||
|
|
||||||
|
## Timing
|
||||||
|
|
||||||
|
- **First chunk**: Usually within 2-5 seconds of request
|
||||||
|
- **Subsequent chunks**: As fast as AI generates (~0.5-2 seconds apart)
|
||||||
|
- **Completion**: 3 minutes after last chunk (idle timeout)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Webhook Processing Guide](webhook-processing.md)
|
||||||
|
- [Session Management](webhook-session-management.md)
|
||||||
|
- [Integration Examples](../examples/webhook-processing/)
|
||||||
302
docs/webhook-session-management.md
Normal file
302
docs/webhook-session-management.md
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
# Webhook Session Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The webhook processing system now includes proper session management to prevent duplicate processing and stale connections when multiple requests use the same `session_id`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```
|
||||||
|
Request 1: session_id=ABC → WebSocket 1 (active)
|
||||||
|
Request 2: session_id=ABC → WebSocket 2 (active)
|
||||||
|
|
||||||
|
Result: Both connections listening → duplicate messages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```
|
||||||
|
Request 1: session_id=ABC → WebSocket 1 (active)
|
||||||
|
Request 2: session_id=ABC → Cancel WebSocket 1 → WebSocket 2 (active)
|
||||||
|
|
||||||
|
Result: Only one connection → no duplicates
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. Connection Tracking
|
||||||
|
|
||||||
|
The webhook processor tracks active WebSocket connections by `session_id`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Processor struct {
|
||||||
|
activeConns map[string]context.CancelFunc // session_id -> cancel function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Cancellation Flow
|
||||||
|
|
||||||
|
When a new request arrives with an existing `session_id`:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Check if session_id already has an active connection
|
||||||
|
2. If yes → cancel the old connection
|
||||||
|
3. Register new connection with session_id
|
||||||
|
4. Process the new request
|
||||||
|
5. Unregister when complete
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Automatic Cleanup
|
||||||
|
|
||||||
|
Connections are automatically unregistered when:
|
||||||
|
- Job completes successfully
|
||||||
|
- Job encounters an error
|
||||||
|
- Context is cancelled
|
||||||
|
- Connection times out
|
||||||
|
|
||||||
|
## Example Scenario
|
||||||
|
|
||||||
|
### Scenario 1: User Interrupts Previous Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User sends first request
|
||||||
|
POST /api/webhook/process
|
||||||
|
{
|
||||||
|
"session_id": "user-123",
|
||||||
|
"payload": {"prompt": "Write a long essay..."}
|
||||||
|
}
|
||||||
|
# → WebSocket connection established
|
||||||
|
|
||||||
|
# User quickly sends second request (different question)
|
||||||
|
POST /api/webhook/process
|
||||||
|
{
|
||||||
|
"session_id": "user-123",
|
||||||
|
"payload": {"prompt": "What's 2+2?"}
|
||||||
|
}
|
||||||
|
# → Previous connection cancelled
|
||||||
|
# → New connection established
|
||||||
|
# → Only second request processes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** User gets answer to "What's 2+2?" immediately, without waiting for the essay.
|
||||||
|
|
||||||
|
### Scenario 2: Conversation Flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First message in conversation
|
||||||
|
POST /api/webhook/process
|
||||||
|
{
|
||||||
|
"session_id": "conv-456",
|
||||||
|
"payload": {"prompt": "What is Python?"}
|
||||||
|
}
|
||||||
|
# → Processes and completes
|
||||||
|
|
||||||
|
# Second message in same conversation
|
||||||
|
POST /api/webhook/process
|
||||||
|
{
|
||||||
|
"session_id": "conv-456",
|
||||||
|
"payload": {"prompt": "Show me an example"}
|
||||||
|
}
|
||||||
|
# → Uses same session (conversation context)
|
||||||
|
# → No old connection to cancel (previous completed)
|
||||||
|
# → Processes normally
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** Both messages processed successfully with conversation context maintained.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Connection Registration
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (p *Processor) processJob(job *Job) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Cancel any existing connection for this session
|
||||||
|
p.cancelExistingConnection(job.SessionID)
|
||||||
|
|
||||||
|
// Register this connection
|
||||||
|
p.registerConnection(job.SessionID, cancel)
|
||||||
|
defer p.unregisterConnection(job.SessionID)
|
||||||
|
|
||||||
|
// ... process request
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancellation Logic
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (p *Processor) cancelExistingConnection(sessionID string) {
|
||||||
|
if cancel, exists := p.activeConns[sessionID]; exists {
|
||||||
|
logger.InfoC("webhook", "Cancelling existing connection for session")
|
||||||
|
cancel() // Cancels the context, closing WebSocket
|
||||||
|
delete(p.activeConns, sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
### With Same session_id
|
||||||
|
|
||||||
|
**Multiple Rapid Requests:**
|
||||||
|
- Only the most recent request is processed
|
||||||
|
- Previous requests are cancelled immediately
|
||||||
|
- No duplicate messages
|
||||||
|
- No wasted resources
|
||||||
|
|
||||||
|
**Sequential Requests:**
|
||||||
|
- Each request completes before next starts
|
||||||
|
- No interference
|
||||||
|
- Conversation context maintained
|
||||||
|
|
||||||
|
### With Different session_id
|
||||||
|
|
||||||
|
**Concurrent Requests:**
|
||||||
|
- Each request has its own connection
|
||||||
|
- All process in parallel
|
||||||
|
- Independent sessions
|
||||||
|
- No interference
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
### When Connection is Cancelled
|
||||||
|
|
||||||
|
```
|
||||||
|
[webhook] Cancelling existing connection for session abc-123
|
||||||
|
[webhook] Context cancelled for job xyz-789, returning partial response
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Connection is Registered
|
||||||
|
|
||||||
|
```
|
||||||
|
[webhook] Registered connection for session abc-123
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Connection Completes
|
||||||
|
|
||||||
|
```
|
||||||
|
[webhook] Unregistered connection for session abc-123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### 1. Request Arrives During Processing
|
||||||
|
|
||||||
|
```
|
||||||
|
Time | Session ABC
|
||||||
|
------|------------------------------------------
|
||||||
|
0:00 | Request 1 starts processing
|
||||||
|
0:05 | Request 1 receives first AI chunk
|
||||||
|
0:10 | Request 2 arrives → cancels Request 1
|
||||||
|
0:11 | Request 1 context cancelled, stops
|
||||||
|
0:12 | Request 2 starts fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Request Arrives After Completion
|
||||||
|
|
||||||
|
```
|
||||||
|
Time | Session ABC
|
||||||
|
------|------------------------------------------
|
||||||
|
0:00 | Request 1 starts processing
|
||||||
|
0:30 | Request 1 completes, unregisters
|
||||||
|
0:35 | Request 2 arrives
|
||||||
|
0:36 | No existing connection, proceeds normally
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Multiple Rapid Requests
|
||||||
|
|
||||||
|
```
|
||||||
|
Time | Session ABC
|
||||||
|
------|------------------------------------------
|
||||||
|
0:00 | Request 1 → Connection 1
|
||||||
|
0:01 | Request 2 → Cancels 1, Connection 2
|
||||||
|
0:02 | Request 3 → Cancels 2, Connection 3
|
||||||
|
0:03 | ... only Connection 3 active
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** Only the last request (Request 3) is processed.
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✅ **No Duplicate Processing**
|
||||||
|
- Only one active connection per session at a time
|
||||||
|
- Previous requests automatically cancelled
|
||||||
|
|
||||||
|
✅ **Resource Efficiency**
|
||||||
|
- Old connections closed immediately
|
||||||
|
- No zombie connections
|
||||||
|
- Reduced memory usage
|
||||||
|
|
||||||
|
✅ **Better User Experience**
|
||||||
|
- Latest request takes priority
|
||||||
|
- No confusion from stale responses
|
||||||
|
- Fast response time
|
||||||
|
|
||||||
|
✅ **Conversation Context**
|
||||||
|
- Same session_id maintains context
|
||||||
|
- Each request builds on previous
|
||||||
|
- Natural conversation flow
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Duplicate Prevention
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Start long request
|
||||||
|
curl -X POST http://localhost:18800/api/webhook/process \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"webhook_url": "https://webhook.site/id-1",
|
||||||
|
"session_id": "test-session",
|
||||||
|
"payload": {"prompt": "Write a 1000-word essay"}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Terminal 2: Immediately send second request (within 1 second)
|
||||||
|
curl -X POST http://localhost:18800/api/webhook/process \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"webhook_url": "https://webhook.site/id-2",
|
||||||
|
"session_id": "test-session",
|
||||||
|
"payload": {"prompt": "What is 2+2?"}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
- First webhook receives partial response (cancelled)
|
||||||
|
- Second webhook receives complete answer to "What is 2+2?"
|
||||||
|
- No messages from essay request in second webhook
|
||||||
|
|
||||||
|
### Test Conversation Flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Message 1
|
||||||
|
curl -X POST http://localhost:18800/api/webhook/process \
|
||||||
|
-d '{
|
||||||
|
"webhook_url": "https://webhook.site/my-id",
|
||||||
|
"session_id": "conversation-1",
|
||||||
|
"payload": {"prompt": "My name is Alice"}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Wait for completion, then Message 2
|
||||||
|
curl -X POST http://localhost:18800/api/webhook/process \
|
||||||
|
-d '{
|
||||||
|
"webhook_url": "https://webhook.site/my-id",
|
||||||
|
"session_id": "conversation-1",
|
||||||
|
"payload": {"prompt": "What is my name?"}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
- First message processes completely
|
||||||
|
- Second message remembers context (answers "Alice")
|
||||||
|
- No cancellation (first completed before second started)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Webhook Processing Guide](webhook-processing.md)
|
||||||
|
- [Session Management](webhook-ai-integration.md)
|
||||||
|
- [Completion Detection](pico-protocol-completion.md)
|
||||||
|
|
@ -288,6 +288,8 @@ func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), e
|
||||||
return func() {
|
return func() {
|
||||||
stopMsg := newMessage(TypeTypingStop, nil)
|
stopMsg := newMessage(TypeTypingStop, nil)
|
||||||
c.broadcastToSession(chatID, stopMsg)
|
c.broadcastToSession(chatID, stopMsg)
|
||||||
|
// Note: Completion marker removed - it was being sent before AI responses
|
||||||
|
// Webhook processing now relies on idle timeout for completion detection
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ const (
|
||||||
TypePing = "ping"
|
TypePing = "ping"
|
||||||
|
|
||||||
// TypeMessageCreate is sent from server to client.
|
// TypeMessageCreate is sent from server to client.
|
||||||
TypeMessageCreate = "message.create"
|
TypeMessageCreate = "message.create"
|
||||||
TypeMessageUpdate = "message.update"
|
TypeMessageUpdate = "message.update"
|
||||||
TypeMediaCreate = "media.create"
|
TypeMessageComplete = "message.complete" // Signals that AI has finished responding
|
||||||
TypeTypingStart = "typing.start"
|
TypeMediaCreate = "media.create"
|
||||||
TypeTypingStop = "typing.stop"
|
TypeTypingStart = "typing.start"
|
||||||
TypeError = "error"
|
TypeTypingStop = "typing.stop"
|
||||||
TypePong = "pong"
|
TypeError = "error"
|
||||||
|
TypePong = "pong"
|
||||||
|
|
||||||
PicoTokenPrefix = "pico-"
|
PicoTokenPrefix = "pico-"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,22 +29,53 @@ func PicoClawProcessor(wsURL, token string) ProcessorFunc {
|
||||||
return nil, fmt.Errorf("prompt must be a string")
|
return nil, fmt.Errorf("prompt must be a string")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract webhook callback info and session from context if available
|
||||||
|
var webhookURL string
|
||||||
|
var jobID string
|
||||||
|
var sessionID string
|
||||||
|
if val := ctx.Value("webhook_url"); val != nil {
|
||||||
|
webhookURL, _ = val.(string)
|
||||||
|
}
|
||||||
|
if val := ctx.Value("job_id"); val != nil {
|
||||||
|
jobID, _ = val.(string)
|
||||||
|
}
|
||||||
|
if val := ctx.Value("session_id"); val != nil {
|
||||||
|
sessionID, _ = val.(string)
|
||||||
|
}
|
||||||
|
|
||||||
// Call PicoClaw AI via WebSocket
|
// Call PicoClaw AI via WebSocket
|
||||||
response, err := callPicoClawAI(ctx, wsURL, token, promptStr)
|
var response string
|
||||||
|
var messageCount int
|
||||||
|
var err error
|
||||||
|
if webhookURL != "" && jobID != "" {
|
||||||
|
// Use streaming mode with callbacks
|
||||||
|
response, messageCount, err = streamPicoClawAI(ctx, wsURL, token, promptStr, sessionID, webhookURL, jobID)
|
||||||
|
} else {
|
||||||
|
// Use non-streaming mode
|
||||||
|
response, err = callPicoClawAI(ctx, wsURL, token, promptStr, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("AI processing failed: %w", err)
|
return nil, fmt.Errorf("AI processing failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return result in expected format
|
// Return result in expected format
|
||||||
return map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"data": response,
|
"data": response,
|
||||||
"error": nil,
|
"error": nil,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Add message_count if streaming was used (signals to skip duplicate final webhook)
|
||||||
|
if messageCount > 0 {
|
||||||
|
result["message_count"] = messageCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// callPicoClawAI sends a message to PicoClaw via WebSocket and waits for response
|
// callPicoClawAI sends a message to PicoClaw via WebSocket and waits for response
|
||||||
func callPicoClawAI(ctx context.Context, wsURL, token, prompt string) (string, error) {
|
func callPicoClawAI(ctx context.Context, wsURL, token, prompt, sessionID string) (string, error) {
|
||||||
// Set up WebSocket connection with timeout
|
// Set up WebSocket connection with timeout
|
||||||
dialer := websocket.Dialer{
|
dialer := websocket.Dialer{
|
||||||
HandshakeTimeout: 10 * time.Second,
|
HandshakeTimeout: 10 * time.Second,
|
||||||
|
|
@ -55,19 +86,18 @@ func callPicoClawAI(ctx context.Context, wsURL, token, prompt string) (string, e
|
||||||
"Authorization": {"Bearer " + token},
|
"Authorization": {"Bearer " + token},
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, _, err := dialer.DialContext(ctx, wsURL, headers)
|
// Add session_id to WebSocket URL if provided
|
||||||
|
wsURLWithSession := wsURL
|
||||||
|
if sessionID != "" {
|
||||||
|
wsURLWithSession = fmt.Sprintf("%s?session_id=%s", wsURL, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, _, err := dialer.DialContext(ctx, wsURLWithSession, headers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to connect to PicoClaw: %w", err)
|
return "", fmt.Errorf("failed to connect to PicoClaw: %w", err)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// Set read deadline
|
|
||||||
deadline := time.Now().Add(2 * time.Minute)
|
|
||||||
if d, ok := ctx.Deadline(); ok {
|
|
||||||
deadline = d
|
|
||||||
}
|
|
||||||
conn.SetReadDeadline(deadline)
|
|
||||||
|
|
||||||
// Send message using Pico Protocol format
|
// Send message using Pico Protocol format
|
||||||
message := map[string]interface{}{
|
message := map[string]interface{}{
|
||||||
"type": "message.send",
|
"type": "message.send",
|
||||||
|
|
@ -87,17 +117,20 @@ func callPicoClawAI(ctx context.Context, wsURL, token, prompt string) (string, e
|
||||||
// The Pico protocol streams responses as multiple message.create messages
|
// The Pico protocol streams responses as multiple message.create messages
|
||||||
var fullResponse string
|
var fullResponse string
|
||||||
var messageCount int
|
var messageCount int
|
||||||
idleTimeout := 2 * time.Second // Wait 2 seconds after last chunk
|
idleTimeout := 3 * time.Minute // Fallback timeout (we have completion marker now)
|
||||||
maxWaitTime := 5 * time.Minute // Maximum total wait time
|
maxWaitTime := 5 * time.Minute // Maximum total wait time
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
receivedFirstMessage := false
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Check overall timeout
|
// Check overall timeout
|
||||||
if time.Since(startTime) > maxWaitTime {
|
if time.Since(startTime) > maxWaitTime {
|
||||||
if fullResponse != "" {
|
if fullResponse != "" {
|
||||||
logger.InfoC("webhook", fmt.Sprintf("Max wait time reached, returning collected response (%d messages)", messageCount))
|
logger.InfoC("webhook", fmt.Sprintf("Max wait time reached, returning collected response (%d messages)", messageCount))
|
||||||
|
conn.Close()
|
||||||
return fullResponse, nil
|
return fullResponse, nil
|
||||||
}
|
}
|
||||||
|
conn.Close()
|
||||||
return "", fmt.Errorf("no response received within maximum wait time")
|
return "", fmt.Errorf("no response received within maximum wait time")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,8 +142,10 @@ func callPicoClawAI(ctx context.Context, wsURL, token, prompt string) (string, e
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
if fullResponse != "" {
|
if fullResponse != "" {
|
||||||
logger.InfoC("webhook", "Context cancelled, returning partial response")
|
logger.InfoC("webhook", "Context cancelled, returning partial response")
|
||||||
|
conn.Close()
|
||||||
return fullResponse, nil
|
return fullResponse, nil
|
||||||
}
|
}
|
||||||
|
conn.Close()
|
||||||
return "", ctx.Err()
|
return "", ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
@ -121,11 +156,13 @@ func callPicoClawAI(ctx context.Context, wsURL, token, prompt string) (string, e
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if this is a timeout (means stream is complete)
|
// Check if this is a timeout (means stream is complete)
|
||||||
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||||
if fullResponse != "" {
|
if receivedFirstMessage && fullResponse != "" {
|
||||||
logger.InfoC("webhook", fmt.Sprintf("Stream complete: received %d message chunks, total length: %d", messageCount, len(fullResponse)))
|
logger.InfoC("webhook", fmt.Sprintf("Stream complete: received %d message chunks, total length: %d", messageCount, len(fullResponse)))
|
||||||
|
conn.Close()
|
||||||
return fullResponse, nil
|
return fullResponse, nil
|
||||||
}
|
}
|
||||||
// No response yet, keep waiting
|
// No response yet, keep waiting
|
||||||
|
logger.DebugC("webhook", "Timeout waiting for messages, continuing...")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,6 +190,7 @@ func callPicoClawAI(ctx context.Context, wsURL, token, prompt string) (string, e
|
||||||
|
|
||||||
if content, ok := payload["content"].(string); ok && content != "" {
|
if content, ok := payload["content"].(string); ok && content != "" {
|
||||||
messageCount++
|
messageCount++
|
||||||
|
receivedFirstMessage = true
|
||||||
fullResponse += content
|
fullResponse += content
|
||||||
logger.DebugC("webhook", fmt.Sprintf("Chunk %d: +%d chars (total: %d)", messageCount, len(content), len(fullResponse)))
|
logger.DebugC("webhook", fmt.Sprintf("Chunk %d: +%d chars (total: %d)", messageCount, len(content), len(fullResponse)))
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +212,7 @@ func callPicoClawAI(ctx context.Context, wsURL, token, prompt string) (string, e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.ErrorC("webhook", fmt.Sprintf("AI returned error: %s", errorMsg))
|
logger.ErrorC("webhook", fmt.Sprintf("AI returned error: %s", errorMsg))
|
||||||
|
conn.Close()
|
||||||
return "", fmt.Errorf("AI error: %s", errorMsg)
|
return "", fmt.Errorf("AI error: %s", errorMsg)
|
||||||
case "pong":
|
case "pong":
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
264
pkg/webhook/picoclaw_streaming_processor.go
Normal file
264
pkg/webhook/picoclaw_streaming_processor.go
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamingCallback is called for each message chunk received
|
||||||
|
type StreamingCallback func(chunk string, isComplete bool) error
|
||||||
|
|
||||||
|
// PicoClawStreamingProcessor creates a processor that streams AI responses via callbacks
|
||||||
|
func PicoClawStreamingProcessor(wsURL, token string, webhookURL string, jobID string) ProcessorFunc {
|
||||||
|
return func(ctx context.Context, payload map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
// Extract prompt from payload
|
||||||
|
prompt, ok := payload["prompt"]
|
||||||
|
if !ok {
|
||||||
|
// If no prompt field, use the entire payload as a string
|
||||||
|
promptBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
prompt = string(promptBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
promptStr, ok := prompt.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("prompt must be a string")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract session_id from context if available
|
||||||
|
sessionID := ""
|
||||||
|
if val := ctx.Value("session_id"); val != nil {
|
||||||
|
sessionID, _ = val.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call PicoClaw AI via WebSocket with streaming callbacks
|
||||||
|
fullResponse, messageCount, err := streamPicoClawAI(ctx, wsURL, token, promptStr, sessionID, webhookURL, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AI processing failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return final result
|
||||||
|
return map[string]interface{}{
|
||||||
|
"data": fullResponse,
|
||||||
|
"message_count": messageCount,
|
||||||
|
"error": nil,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamPicoClawAI connects to PicoClaw and streams responses via webhook callbacks
|
||||||
|
func streamPicoClawAI(ctx context.Context, wsURL, token, prompt, sessionID, webhookURL, jobID string) (string, int, error) {
|
||||||
|
// Set up WebSocket connection with timeout
|
||||||
|
dialer := websocket.Dialer{
|
||||||
|
HandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add token to Authorization header (Bearer authentication)
|
||||||
|
headers := map[string][]string{
|
||||||
|
"Authorization": {"Bearer " + token},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add session_id to WebSocket URL if provided
|
||||||
|
wsURLWithSession := wsURL
|
||||||
|
if sessionID != "" {
|
||||||
|
wsURLWithSession = fmt.Sprintf("%s?session_id=%s", wsURL, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, _, err := dialer.DialContext(ctx, wsURLWithSession, headers)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("failed to connect to PicoClaw: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Send message using Pico Protocol format
|
||||||
|
message := map[string]interface{}{
|
||||||
|
"type": "message.send",
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
"payload": map[string]interface{}{
|
||||||
|
"content": prompt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.WriteJSON(message); err != nil {
|
||||||
|
return "", 0, fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoC("webhook", fmt.Sprintf("Sent prompt to PicoClaw for job %s", jobID))
|
||||||
|
|
||||||
|
// Read responses and send webhook callback for each chunk
|
||||||
|
var fullResponse string
|
||||||
|
var messageCount int
|
||||||
|
idleTimeout := 3 * time.Minute // Fallback timeout (we have completion marker now)
|
||||||
|
maxWaitTime := 5 * time.Minute // Maximum total wait time
|
||||||
|
startTime := time.Now()
|
||||||
|
receivedFirstMessage := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Check overall timeout
|
||||||
|
if time.Since(startTime) > maxWaitTime {
|
||||||
|
if fullResponse != "" {
|
||||||
|
logger.InfoC("webhook", fmt.Sprintf("Max wait time reached for job %s, collected %d messages", jobID, messageCount))
|
||||||
|
// Send final completion callback
|
||||||
|
sendStreamingWebhook(webhookURL, jobID, sessionID, "", true, fullResponse, messageCount, nil)
|
||||||
|
// Close connection immediately after completion
|
||||||
|
conn.Close()
|
||||||
|
return fullResponse, messageCount, nil
|
||||||
|
}
|
||||||
|
return "", 0, fmt.Errorf("no response received within maximum wait time")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a read deadline to detect when stream is complete
|
||||||
|
conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if fullResponse != "" {
|
||||||
|
logger.InfoC("webhook", fmt.Sprintf("Context cancelled for job %s, returning partial response", jobID))
|
||||||
|
sendStreamingWebhook(webhookURL, jobID, sessionID, "", true, fullResponse, messageCount, nil)
|
||||||
|
// Close connection immediately
|
||||||
|
conn.Close()
|
||||||
|
return fullResponse, messageCount, nil
|
||||||
|
}
|
||||||
|
return "", 0, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg map[string]interface{}
|
||||||
|
err := conn.ReadJSON(&msg)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Check if this is a timeout (means stream is complete)
|
||||||
|
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||||
|
if receivedFirstMessage && fullResponse != "" {
|
||||||
|
logger.InfoC("webhook", fmt.Sprintf("Stream complete for job %s: %d messages, %d chars", jobID, messageCount, len(fullResponse)))
|
||||||
|
// Send final completion callback
|
||||||
|
sendStreamingWebhook(webhookURL, jobID, sessionID, "", true, fullResponse, messageCount, nil)
|
||||||
|
// Close connection immediately after completion
|
||||||
|
conn.Close()
|
||||||
|
return fullResponse, messageCount, nil
|
||||||
|
}
|
||||||
|
// No response yet, keep waiting
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("Timeout waiting for messages (job %s), continuing...", jobID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||||
|
if fullResponse != "" {
|
||||||
|
logger.InfoC("webhook", fmt.Sprintf("Connection closed for job %s, %d messages collected", jobID, messageCount))
|
||||||
|
sendStreamingWebhook(webhookURL, jobID, sessionID, "", true, fullResponse, messageCount, nil)
|
||||||
|
// Connection already closed by error
|
||||||
|
return fullResponse, messageCount, nil
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return "", 0, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgType, _ := msg["type"].(string)
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("Job %s received message type: %s", jobID, msgType))
|
||||||
|
|
||||||
|
switch msgType {
|
||||||
|
case "message.create":
|
||||||
|
// Extract content from payload
|
||||||
|
if payload, ok := msg["payload"].(map[string]interface{}); ok {
|
||||||
|
// Check if this is a thought message (skip it)
|
||||||
|
if thought, ok := payload["thought"].(bool); ok && thought {
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("Received thought message for job %s (skipping)", jobID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if content, ok := payload["content"].(string); ok && content != "" {
|
||||||
|
messageCount++
|
||||||
|
receivedFirstMessage = true
|
||||||
|
fullResponse += content
|
||||||
|
|
||||||
|
logger.InfoC("webhook", fmt.Sprintf("Job %s - Chunk %d: +%d chars (total: %d)", jobID, messageCount, len(content), len(fullResponse)))
|
||||||
|
|
||||||
|
// Send webhook callback for this chunk
|
||||||
|
if err := sendStreamingWebhook(webhookURL, jobID, sessionID, content, false, fullResponse, messageCount, nil); err != nil {
|
||||||
|
logger.ErrorC("webhook", fmt.Sprintf("Failed to send webhook callback for job %s chunk %d: %v", jobID, messageCount, err))
|
||||||
|
// Continue processing even if webhook fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "typing.start":
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("AI started typing for job %s", jobID))
|
||||||
|
continue
|
||||||
|
case "typing.stop":
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("AI stopped typing for job %s", jobID))
|
||||||
|
continue
|
||||||
|
case "error":
|
||||||
|
// Extract error from payload
|
||||||
|
errorMsg := "unknown error"
|
||||||
|
if payload, ok := msg["payload"].(map[string]interface{}); ok {
|
||||||
|
if message, ok := payload["message"].(string); ok {
|
||||||
|
errorMsg = message
|
||||||
|
} else if code, ok := payload["code"].(string); ok {
|
||||||
|
errorMsg = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.ErrorC("webhook", fmt.Sprintf("AI returned error for job %s: %s", jobID, errorMsg))
|
||||||
|
// Send error webhook
|
||||||
|
sendStreamingWebhook(webhookURL, jobID, sessionID, "", true, "", 0, fmt.Errorf("%s", errorMsg))
|
||||||
|
// Close connection immediately after error
|
||||||
|
conn.Close()
|
||||||
|
return "", 0, fmt.Errorf("AI error: %s", errorMsg)
|
||||||
|
case "pong":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("Received unknown message type for job %s: %s", jobID, msgType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fullResponse == "" {
|
||||||
|
return "No response received", 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullResponse, messageCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendStreamingWebhook sends a webhook callback for each chunk
|
||||||
|
func sendStreamingWebhook(webhookURL, jobID, sessionID, chunk string, isComplete bool, fullResponse string, messageCount int, err error) error {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"session_id": sessionID,
|
||||||
|
"timestamp": time.Now(),
|
||||||
|
"is_complete": isComplete,
|
||||||
|
"message_count": messageCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
payload["status"] = "failed"
|
||||||
|
payload["error"] = err.Error()
|
||||||
|
} else if isComplete {
|
||||||
|
payload["status"] = "completed"
|
||||||
|
// Don't send full response in completion - already sent via streaming chunks
|
||||||
|
payload["result"] = map[string]interface{}{
|
||||||
|
"message_count": messageCount,
|
||||||
|
"error": nil,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload["status"] = "streaming"
|
||||||
|
payload["message"] = chunk
|
||||||
|
payload["accumulated_length"] = len(fullResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := NewWebhookRequest(webhookURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return SendWebhookRequest(req)
|
||||||
|
}
|
||||||
|
|
@ -17,11 +17,13 @@ import (
|
||||||
type ProcessRequest struct {
|
type ProcessRequest struct {
|
||||||
WebhookURL string `json:"webhook_url"`
|
WebhookURL string `json:"webhook_url"`
|
||||||
Payload map[string]interface{} `json:"payload"`
|
Payload map[string]interface{} `json:"payload"`
|
||||||
|
SessionID string `json:"session_id,omitempty"` // Optional session ID for conversation context
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessResponse is returned immediately when a job is accepted
|
// ProcessResponse is returned immediately when a job is accepted
|
||||||
type ProcessResponse struct {
|
type ProcessResponse struct {
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
|
SessionID string `json:"session_id"` // Session ID for conversation context
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +31,7 @@ type ProcessResponse struct {
|
||||||
// WebhookPayload is sent to the webhook URL when processing completes
|
// WebhookPayload is sent to the webhook URL when processing completes
|
||||||
type WebhookPayload struct {
|
type WebhookPayload struct {
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
|
SessionID string `json:"session_id"` // Session ID for conversation context
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Result map[string]interface{} `json:"result,omitempty"`
|
Result map[string]interface{} `json:"result,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
|
@ -37,19 +40,22 @@ type WebhookPayload struct {
|
||||||
|
|
||||||
// Processor handles async processing and webhook callbacks
|
// Processor handles async processing and webhook callbacks
|
||||||
type Processor struct {
|
type Processor struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
jobs map[string]*Job
|
jobs map[string]*Job
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
processorFn ProcessorFunc
|
processorFn ProcessorFunc
|
||||||
|
activeConnsMu sync.Mutex
|
||||||
|
activeConns map[string]context.CancelFunc // session_id -> cancel function
|
||||||
}
|
}
|
||||||
|
|
||||||
// Job tracks the state of an async job
|
// Job tracks the state of an async job
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID string
|
ID string
|
||||||
WebhookURL string
|
WebhookURL string
|
||||||
Payload map[string]interface{}
|
Payload map[string]interface{}
|
||||||
Status string
|
SessionID string // Session ID for conversation context
|
||||||
CreatedAt time.Time
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,6 +70,7 @@ func NewProcessor(processorFn ProcessorFunc) *Processor {
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
processorFn: processorFn,
|
processorFn: processorFn,
|
||||||
|
activeConns: make(map[string]context.CancelFunc),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,10 +81,18 @@ func (p *Processor) Submit(req ProcessRequest) (*ProcessResponse, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
jobID := uuid.New().String()
|
jobID := uuid.New().String()
|
||||||
|
|
||||||
|
// Use provided session_id or generate a new one
|
||||||
|
sessionID := req.SessionID
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
job := &Job{
|
job := &Job{
|
||||||
ID: jobID,
|
ID: jobID,
|
||||||
WebhookURL: req.WebhookURL,
|
WebhookURL: req.WebhookURL,
|
||||||
Payload: req.Payload,
|
Payload: req.Payload,
|
||||||
|
SessionID: sessionID,
|
||||||
Status: "processing",
|
Status: "processing",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
@ -91,11 +106,13 @@ func (p *Processor) Submit(req ProcessRequest) (*ProcessResponse, error) {
|
||||||
|
|
||||||
logger.InfoCF("webhook", "Job submitted", map[string]any{
|
logger.InfoCF("webhook", "Job submitted", map[string]any{
|
||||||
"job_id": jobID,
|
"job_id": jobID,
|
||||||
|
"session_id": sessionID,
|
||||||
"webhook_url": req.WebhookURL,
|
"webhook_url": req.WebhookURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
return &ProcessResponse{
|
return &ProcessResponse{
|
||||||
JobID: jobID,
|
JobID: jobID,
|
||||||
|
SessionID: sessionID,
|
||||||
Status: "processing",
|
Status: "processing",
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -109,11 +126,65 @@ func (p *Processor) GetJob(jobID string) (*Job, bool) {
|
||||||
return job, exists
|
return job, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancelExistingConnection cancels any existing WebSocket connection for the session
|
||||||
|
func (p *Processor) cancelExistingConnection(sessionID string) {
|
||||||
|
if sessionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.activeConnsMu.Lock()
|
||||||
|
defer p.activeConnsMu.Unlock()
|
||||||
|
|
||||||
|
if cancel, exists := p.activeConns[sessionID]; exists {
|
||||||
|
logger.InfoC("webhook", fmt.Sprintf("Cancelling existing connection for session %s", sessionID))
|
||||||
|
cancel()
|
||||||
|
delete(p.activeConns, sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerConnection registers a new active connection for the session
|
||||||
|
func (p *Processor) registerConnection(sessionID string, cancel context.CancelFunc) {
|
||||||
|
if sessionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.activeConnsMu.Lock()
|
||||||
|
defer p.activeConnsMu.Unlock()
|
||||||
|
|
||||||
|
p.activeConns[sessionID] = cancel
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("Registered connection for session %s", sessionID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// unregisterConnection removes the connection registration for the session
|
||||||
|
func (p *Processor) unregisterConnection(sessionID string) {
|
||||||
|
if sessionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.activeConnsMu.Lock()
|
||||||
|
defer p.activeConnsMu.Unlock()
|
||||||
|
|
||||||
|
delete(p.activeConns, sessionID)
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("Unregistered connection for session %s", sessionID))
|
||||||
|
}
|
||||||
|
|
||||||
// processJob executes the processing and calls webhook
|
// processJob executes the processing and calls webhook
|
||||||
func (p *Processor) processJob(job *Job) {
|
func (p *Processor) processJob(job *Job) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Cancel any existing connection for this session
|
||||||
|
p.cancelExistingConnection(job.SessionID)
|
||||||
|
|
||||||
|
// Register this connection
|
||||||
|
p.registerConnection(job.SessionID, cancel)
|
||||||
|
defer p.unregisterConnection(job.SessionID)
|
||||||
|
|
||||||
|
// Add webhook info and session to context for streaming processors
|
||||||
|
ctx = context.WithValue(ctx, "webhook_url", job.WebhookURL)
|
||||||
|
ctx = context.WithValue(ctx, "job_id", job.ID)
|
||||||
|
ctx = context.WithValue(ctx, "session_id", job.SessionID)
|
||||||
|
|
||||||
logger.InfoCF("webhook", "Processing job started", map[string]any{
|
logger.InfoCF("webhook", "Processing job started", map[string]any{
|
||||||
"job_id": job.ID,
|
"job_id": job.ID,
|
||||||
})
|
})
|
||||||
|
|
@ -128,35 +199,83 @@ func (p *Processor) processJob(job *Job) {
|
||||||
job.Status = "failed"
|
job.Status = "failed"
|
||||||
webhookPayload = WebhookPayload{
|
webhookPayload = WebhookPayload{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
|
SessionID: job.SessionID,
|
||||||
Status: "failed",
|
Status: "failed",
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
Timestamp: completedAt,
|
Timestamp: completedAt,
|
||||||
}
|
}
|
||||||
logger.ErrorCF("webhook", "Job processing failed", map[string]any{
|
logger.ErrorCF("webhook", "Job processing failed", map[string]any{
|
||||||
"job_id": job.ID,
|
"job_id": job.ID,
|
||||||
"error": err.Error(),
|
"session_id": job.SessionID,
|
||||||
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
job.Status = "completed"
|
job.Status = "completed"
|
||||||
webhookPayload = WebhookPayload{
|
webhookPayload = WebhookPayload{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
|
SessionID: job.SessionID,
|
||||||
Status: "completed",
|
Status: "completed",
|
||||||
Result: result,
|
Result: result,
|
||||||
Timestamp: completedAt,
|
Timestamp: completedAt,
|
||||||
}
|
}
|
||||||
logger.InfoCF("webhook", "Job processing completed", map[string]any{
|
logger.InfoCF("webhook", "Job processing completed", map[string]any{
|
||||||
"job_id": job.ID,
|
"job_id": job.ID,
|
||||||
|
"session_id": job.SessionID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call webhook
|
// Check if streaming mode was used (streaming sends its own final callback)
|
||||||
if err := p.callWebhook(job.WebhookURL, webhookPayload); err != nil {
|
isStreamingMode := false
|
||||||
logger.ErrorCF("webhook", "Webhook callback failed", map[string]any{
|
if result != nil {
|
||||||
"job_id": job.ID,
|
if _, hasMessageCount := result["message_count"]; hasMessageCount {
|
||||||
"webhook_url": job.WebhookURL,
|
isStreamingMode = true
|
||||||
"error": err.Error(),
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Call webhook only if not in streaming mode (streaming already sent final callback)
|
||||||
|
if !isStreamingMode {
|
||||||
|
if err := p.callWebhook(job.WebhookURL, webhookPayload); err != nil {
|
||||||
|
logger.ErrorCF("webhook", "Webhook callback failed", map[string]any{
|
||||||
|
"job_id": job.ID,
|
||||||
|
"webhook_url": job.WebhookURL,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.DebugC("webhook", fmt.Sprintf("Skipping final webhook for job %s (streaming mode already sent completion)", job.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebhookRequest creates an HTTP request for webhook callback
|
||||||
|
func NewWebhookRequest(webhookURL string, body []byte) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create webhook request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "PicoClaw-Webhook/1.0")
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendWebhookRequest sends a webhook HTTP request
|
||||||
|
func SendWebhookRequest(req *http.Request) error {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("webhook request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("webhook returned non-2xx status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// callWebhook sends the result to the webhook URL
|
// callWebhook sends the result to the webhook URL
|
||||||
|
|
@ -166,19 +285,16 @@ func (p *Processor) callWebhook(webhookURL string, payload WebhookPayload) error
|
||||||
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create webhook request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", "PicoClaw-Webhook/1.0")
|
|
||||||
|
|
||||||
logger.InfoCF("webhook", "Calling webhook", map[string]any{
|
logger.InfoCF("webhook", "Calling webhook", map[string]any{
|
||||||
"url": webhookURL,
|
"url": webhookURL,
|
||||||
"job_id": payload.JobID,
|
"job_id": payload.JobID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
req, err := NewWebhookRequest(webhookURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("webhook request failed: %w", err)
|
return fmt.Errorf("webhook request failed: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ func (h *Handler) handleWebhookStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(job)
|
json.NewEncoder(w).Encode(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWebhookProcessor returns the webhook processor instance
|
// getWebhookProcessor returns the webhook processor instance (non-streaming)
|
||||||
// It lazily initializes the processor on first use
|
// It lazily initializes the processor on first use
|
||||||
func (h *Handler) getWebhookProcessor() *webhook.Processor {
|
func (h *Handler) getWebhookProcessor() *webhook.Processor {
|
||||||
h.webhookMu.Lock()
|
h.webhookMu.Lock()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue