173 lines
5.4 KiB
Go
173 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type claudeStreamBuilder struct {
|
|
model string
|
|
messageID string
|
|
toolUseID string
|
|
index int
|
|
inputTokens int
|
|
}
|
|
|
|
func newClaudeStreamBuilder(model string) *claudeStreamBuilder {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
model = "claude-sonnet-4-6"
|
|
}
|
|
now := time.Now().UnixNano()
|
|
return &claudeStreamBuilder{
|
|
model: model,
|
|
messageID: fmt.Sprintf("msg_%x", now),
|
|
toolUseID: fmt.Sprintf("srvtoolu_%d", now),
|
|
inputTokens: 85,
|
|
}
|
|
}
|
|
|
|
func (b *claudeStreamBuilder) buildStreamWithQuery(query string, hits []claudeWebSearchHit, answer string) []byte {
|
|
var chunks []string
|
|
chunks = append(chunks, b.event("message_start", map[string]any{
|
|
"type": "message_start",
|
|
"message": map[string]any{
|
|
"id": b.messageID, "type": "message", "role": "assistant", "content": []any{},
|
|
"model": b.model, "stop_reason": nil, "stop_sequence": nil,
|
|
"usage": map[string]any{"input_tokens": b.inputTokens, "output_tokens": 0},
|
|
},
|
|
}))
|
|
chunks = append(chunks, b.blockStart(b.index, map[string]any{
|
|
"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]any{},
|
|
}))
|
|
partial, _ := json.Marshal(map[string]string{"query": query})
|
|
chunks = append(chunks, b.event("content_block_delta", map[string]any{
|
|
"type": "content_block_delta", "index": b.index,
|
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(partial)},
|
|
}))
|
|
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
|
b.index++
|
|
|
|
resultContent := webSearchResultBlocks(hits)
|
|
chunks = append(chunks, b.blockStart(b.index, map[string]any{
|
|
"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": resultContent,
|
|
}))
|
|
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
|
b.index++
|
|
|
|
text := composeAnswerText(answer, hits)
|
|
outputTokens := estimateTokens(text)
|
|
chunks = append(chunks, b.blockStart(b.index, map[string]any{"type": "text", "text": ""}))
|
|
chunks = append(chunks, b.event("content_block_delta", map[string]any{
|
|
"type": "content_block_delta", "index": b.index,
|
|
"delta": map[string]any{"type": "text_delta", "text": text},
|
|
}))
|
|
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
|
|
|
chunks = append(chunks, b.event("message_delta", map[string]any{
|
|
"type": "message_delta",
|
|
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
|
|
"usage": map[string]any{
|
|
"input_tokens": b.inputTokens, "output_tokens": outputTokens,
|
|
"server_tool_use": map[string]any{"web_search_requests": 1},
|
|
},
|
|
}))
|
|
chunks = append(chunks, b.event("message_stop", map[string]any{"type": "message_stop"}))
|
|
return []byte(strings.Join(chunks, ""))
|
|
}
|
|
|
|
func (b *claudeStreamBuilder) buildMessageJSON(query string, hits []claudeWebSearchHit, answer string) []byte {
|
|
text := composeAnswerText(answer, hits)
|
|
content := []map[string]any{
|
|
{"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]string{"query": query}},
|
|
{"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": webSearchResultBlocks(hits)},
|
|
{"type": "text", "text": text},
|
|
}
|
|
out := map[string]any{
|
|
"id": b.messageID, "type": "message", "role": "assistant", "model": b.model,
|
|
"content": content, "stop_reason": "end_turn", "stop_sequence": nil,
|
|
"usage": map[string]any{
|
|
"input_tokens": b.inputTokens, "output_tokens": estimateTokens(text),
|
|
"server_tool_use": map[string]any{"web_search_requests": 1},
|
|
},
|
|
}
|
|
raw, _ := json.Marshal(out)
|
|
return raw
|
|
}
|
|
|
|
func webSearchResultBlocks(hits []claudeWebSearchHit) []map[string]any {
|
|
resultContent := make([]map[string]any, 0, len(hits))
|
|
for _, hit := range hits {
|
|
title := hit.Title
|
|
if title == "" {
|
|
title = hostFromURL(hit.URL)
|
|
}
|
|
resultContent = append(resultContent, map[string]any{
|
|
"type": "web_search_result", "title": title, "url": hit.URL, "page_age": nil,
|
|
})
|
|
}
|
|
return resultContent
|
|
}
|
|
|
|
func (b *claudeStreamBuilder) event(eventType string, data map[string]any) string {
|
|
raw, _ := json.Marshal(data)
|
|
return fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, string(raw))
|
|
}
|
|
|
|
func (b *claudeStreamBuilder) blockStart(index int, block map[string]any) string {
|
|
return b.event("content_block_start", map[string]any{
|
|
"type": "content_block_start", "index": index, "content_block": block,
|
|
})
|
|
}
|
|
|
|
func composeAnswerText(answer string, hits []claudeWebSearchHit) string {
|
|
if strings.TrimSpace(answer) != "" {
|
|
return answer
|
|
}
|
|
if len(hits) == 0 {
|
|
return "No web search results were returned."
|
|
}
|
|
var buf strings.Builder
|
|
for i, hit := range hits {
|
|
if i > 0 {
|
|
buf.WriteString("\n\n")
|
|
}
|
|
if hit.Title != "" {
|
|
buf.WriteString(hit.Title)
|
|
buf.WriteString("\n")
|
|
}
|
|
if hit.URL != "" {
|
|
buf.WriteString(hit.URL)
|
|
buf.WriteString("\n")
|
|
}
|
|
if hit.Snippet != "" {
|
|
buf.WriteString(hit.Snippet)
|
|
}
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func hostFromURL(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
withoutScheme := raw
|
|
if idx := strings.Index(raw, "://"); idx >= 0 {
|
|
withoutScheme = raw[idx+3:]
|
|
}
|
|
if slash := strings.Index(withoutScheme, "/"); slash >= 0 {
|
|
return withoutScheme[:slash]
|
|
}
|
|
return withoutScheme
|
|
}
|
|
|
|
func estimateTokens(text string) int {
|
|
n := len([]rune(text)) / 4
|
|
if n < 1 {
|
|
return 1
|
|
}
|
|
return n
|
|
}
|