diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 43f766e..16f01c0 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -23,14 +23,14 @@ import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/strea * @param {object} options.credentials - Provider credentials * @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses") */ -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, sourceFormatOverride }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, sourceFormatOverride }) { const { provider, model } = modelInfo; const requestStartTime = Date.now(); const sourceFormat = sourceFormatOverride || detectFormat(body); - // Check for bypass patterns (warmup, skip) - const bypassResponse = handleBypassRequest(body, model, userAgent); + // Check for bypass patterns (warmup, skip, cc naming) + const bypassResponse = handleBypassRequest(body, model, userAgent, ccFilterNaming); if (bypassResponse) return bypassResponse; const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; diff --git a/open-sse/utils/bypassHandler.js b/open-sse/utils/bypassHandler.js index 1c57dc5..57fa2ff 100644 --- a/open-sse/utils/bypassHandler.js +++ b/open-sse/utils/bypassHandler.js @@ -8,7 +8,7 @@ import { formatSSE } from "./stream.js"; * Check for bypass patterns - return fake response without calling provider * Only works for Claude CLI requests */ -export function handleBypassRequest(body, model, userAgent = "") { +export function handleBypassRequest(body, model, userAgent = "", ccFilterNaming = false) { if (!userAgent.includes("claude-cli")) return null; if (!body.messages?.length) return null; @@ -22,6 +22,7 @@ export function handleBypassRequest(body, model, userAgent = "") { }; let shouldBypass = false; + let namingBypass = false; // Pattern 1: Title extraction (assistant message = "{") const lastMsg = messages[messages.length - 1]; @@ -54,23 +55,50 @@ export function handleBypassRequest(body, model, userAgent = "") { } } + // Pattern 5: CC naming request (topic title extraction by Claude Code CLI) + // Claude format: system is top-level body.system field, not inside messages + if (!shouldBypass && ccFilterNaming) { + const systemMsg = messages.find(m => m.role === "system"); + const systemFromMessages = getText(systemMsg?.content); + const systemFromBody = Array.isArray(body.system) + ? body.system.filter(s => s.type === "text").map(s => s.text).join(" ") + : (typeof body.system === "string" ? body.system : ""); + const systemText = systemFromMessages || systemFromBody; + if (systemText.includes("isNewTopic")) { + shouldBypass = true; + namingBypass = true; + } + } + if (!shouldBypass) return null; const sourceFormat = detectFormat(body); const stream = body.stream !== false; + // For naming bypass, generate title from user message + if (namingBypass) { + const userMsg = messages.find(m => m.role === "user"); + const userText = getText(userMsg?.content); + const title = userText.trim().split(/\s+/).slice(0, 3).join(" "); + const namingText = JSON.stringify({ isNewTopic: true, title }); + return stream + ? createStreamingResponse(sourceFormat, model, namingText) + : createNonStreamingResponse(sourceFormat, model, namingText); + } + return stream ? createStreamingResponse(sourceFormat, model) : createNonStreamingResponse(sourceFormat, model); } +const DEFAULT_BYPASS_TEXT = "CLI Command Execution: Clear Terminal"; + /** * Create OpenAI standard format response */ -function createOpenAIResponse(model) { +function createOpenAIResponse(model, text = DEFAULT_BYPASS_TEXT) { const id = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); - const text = "CLI Command Execution: Clear Terminal"; return { id, @@ -97,8 +125,8 @@ function createOpenAIResponse(model) { * Create non-streaming response with translation * Use translator to convert OpenAI → sourceFormat */ -function createNonStreamingResponse(sourceFormat, model) { - const openaiResponse = createOpenAIResponse(model); +function createNonStreamingResponse(sourceFormat, model, text) { + const openaiResponse = createOpenAIResponse(model, text); // If sourceFormat is OpenAI, return directly if (sourceFormat === FORMATS.OPENAI) { @@ -151,8 +179,8 @@ function createNonStreamingResponse(sourceFormat, model) { * Create streaming response with translation * Use translator to convert OpenAI chunks → sourceFormat */ -function createStreamingResponse(sourceFormat, model) { - const openaiResponse = createOpenAIResponse(model); +function createStreamingResponse(sourceFormat, model, text) { + const openaiResponse = createOpenAIResponse(model, text); const state = initState(sourceFormat); state.model = model; diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 4f5e4a0..77946bf 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -162,7 +162,9 @@ export function createSSEStream(options = {}) { const parsed = parseSSELine(trimmed, targetFormat); if (!parsed) continue; - if (parsed && parsed.done) { + // For Ollama: done=true is the final chunk with finish_reason/usage, must translate + // For other formats: done=true is the [DONE] sentinel, skip + if (parsed && parsed.done && targetFormat !== FORMATS.OLLAMA) { const output = "data: [DONE]\n\n"; reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 2db68c9..e518b9b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components"; import Image from "next/image"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -31,6 +31,7 @@ export default function ClaudeToolCard({ const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); + const [ccFilterNaming, setCcFilterNaming] = useState(false); const hasInitializedModels = useRef(false); const getConfigStatus = () => { @@ -64,6 +65,22 @@ export default function ClaudeToolCard({ if (isExpanded) fetchModelAliases(); }, [isExpanded]); + useEffect(() => { + fetch("/api/settings").then(r => r.json()).then(data => { + setCcFilterNaming(!!data.ccFilterNaming); + }).catch(() => {}); + }, []); + + const handleCcFilterNamingToggle = async (e) => { + const value = e.target.checked; + setCcFilterNaming(value); + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ccFilterNaming: value }), + }).catch(() => {}); + }; + const fetchModelAliases = async () => { try { const res = await fetch("/api/models/alias"); @@ -319,6 +336,19 @@ export default function ClaudeToolCard({ {modelMappings[model.alias] && } ))} + + {/* CC Filter Naming */} +