mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat: add provider-native request switches (#5254)
This commit is contained in:
@@ -276,6 +276,52 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
|
||||
|
||||
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
|
||||
const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]);
|
||||
type ProviderRequestOption = {
|
||||
kind: "priority" | "hosted_tool";
|
||||
titleKey: string;
|
||||
title: string;
|
||||
helpKey: string;
|
||||
help: string;
|
||||
toolType?: "web_search" | "x_search";
|
||||
defaultEnabled?: boolean;
|
||||
forceResponses?: boolean;
|
||||
};
|
||||
const PROVIDER_REQUEST_OPTIONS: Partial<Record<string, ProviderRequestOption[]>> = {
|
||||
openai_codex: [{
|
||||
kind: "priority",
|
||||
titleKey: "settings.providers.capabilityFastMode",
|
||||
title: "Fast mode",
|
||||
helpKey: "settings.providers.capabilityFastModeHelp",
|
||||
help: "Use OpenAI's priority service tier for faster responses. This consumes credits faster.",
|
||||
}],
|
||||
openai: [{
|
||||
kind: "hosted_tool",
|
||||
titleKey: "settings.providers.capabilityOpenAISearch",
|
||||
title: "OpenAI web search",
|
||||
helpKey: "settings.providers.capabilityOpenAISearchHelp",
|
||||
help: "Allow compatible Responses API models to search the web. Search activity appears in chat.",
|
||||
toolType: "web_search",
|
||||
forceResponses: true,
|
||||
}],
|
||||
deepseek: [{
|
||||
kind: "hosted_tool",
|
||||
titleKey: "settings.providers.capabilityDeepSeekSearch",
|
||||
title: "DeepSeek web search",
|
||||
helpKey: "settings.providers.capabilityDeepSeekSearchHelp",
|
||||
help: "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.",
|
||||
toolType: "web_search",
|
||||
defaultEnabled: true,
|
||||
}],
|
||||
xai_grok: [{
|
||||
kind: "hosted_tool",
|
||||
titleKey: "settings.providers.capabilityXSearch",
|
||||
title: "X Search",
|
||||
helpKey: "settings.providers.capabilityXSearchHelp",
|
||||
help: "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.",
|
||||
toolType: "x_search",
|
||||
defaultEnabled: true,
|
||||
}],
|
||||
};
|
||||
const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__";
|
||||
const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [
|
||||
"extra_headers",
|
||||
@@ -306,6 +352,70 @@ function providerJsonValue(value: Record<string, unknown> | null | undefined): s
|
||||
return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : "";
|
||||
}
|
||||
|
||||
function parseProviderExtraBody(value: string): Record<string, unknown> | null {
|
||||
if (!value.trim()) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isHostedSearchTool(tool: unknown, toolType: "web_search" | "x_search"): boolean {
|
||||
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
|
||||
const configuredType = (tool as Record<string, unknown>).type;
|
||||
if (typeof configuredType !== "string") return false;
|
||||
return configuredType === toolType
|
||||
|| (toolType === "web_search" && configuredType.startsWith("web_search_"));
|
||||
}
|
||||
|
||||
function hasHostedSearchTool(value: unknown, toolType: "web_search" | "x_search"): boolean {
|
||||
return Array.isArray(value) && value.some((tool) => isHostedSearchTool(tool, toolType));
|
||||
}
|
||||
|
||||
function providerRequestOptionEnabled(
|
||||
option: ProviderRequestOption,
|
||||
extraBody: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (option.kind === "priority") return extraBody.service_tier === "priority";
|
||||
if (Object.prototype.hasOwnProperty.call(extraBody, "tools")) {
|
||||
return hasHostedSearchTool(extraBody.tools, option.toolType!);
|
||||
}
|
||||
return option.defaultEnabled === true;
|
||||
}
|
||||
|
||||
function updateProviderRequestOption(
|
||||
option: ProviderRequestOption,
|
||||
enabled: boolean,
|
||||
form: ProviderForm,
|
||||
): Partial<ProviderForm> {
|
||||
const extraBody = { ...(parseProviderExtraBody(form.extraBody) ?? {}) };
|
||||
if (option.kind === "priority") {
|
||||
if (enabled) extraBody.service_tier = "priority";
|
||||
else if (extraBody.service_tier === "priority") delete extraBody.service_tier;
|
||||
} else {
|
||||
const toolType = option.toolType!;
|
||||
const tools = Array.isArray(extraBody.tools)
|
||||
? extraBody.tools.filter((tool) => !isHostedSearchTool(tool, toolType))
|
||||
: [];
|
||||
if (enabled) tools.push({ type: toolType });
|
||||
if (tools.length || option.defaultEnabled) {
|
||||
extraBody.tools = tools;
|
||||
} else {
|
||||
delete extraBody.tools;
|
||||
}
|
||||
}
|
||||
return {
|
||||
extraBody: providerJsonValue(extraBody),
|
||||
...(option.forceResponses && enabled
|
||||
? { apiType: "responses" as const }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function providerFormFromRow(
|
||||
provider: SettingsPayload["providers"][number],
|
||||
): ProviderForm {
|
||||
@@ -3852,6 +3962,62 @@ function ModelAdvancedFields({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRequestOptions({
|
||||
providerName,
|
||||
form,
|
||||
onChange,
|
||||
}: {
|
||||
providerName: string;
|
||||
form: ProviderForm;
|
||||
onChange: (value: Partial<ProviderForm>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = PROVIDER_REQUEST_OPTIONS[providerName] ?? [];
|
||||
if (options.length === 0) return null;
|
||||
const extraBody = parseProviderExtraBody(form.extraBody) ?? {};
|
||||
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[18px] border border-border/45 bg-background/75">
|
||||
{options.map((option, index) => {
|
||||
const title = tx(option.titleKey, option.title);
|
||||
const Icon = option.kind === "priority" ? Zap : Globe2;
|
||||
const checked = providerRequestOptionEnabled(option, extraBody);
|
||||
return (
|
||||
<div
|
||||
key={option.titleKey}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 px-4 py-3",
|
||||
index > 0 && "border-t border-border/45",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold text-foreground">{title}</p>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||
{tx(option.helpKey, option.help)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleButton
|
||||
checked={checked}
|
||||
onChange={(enabled) => onChange(
|
||||
updateProviderRequestOption(option, enabled, form),
|
||||
)}
|
||||
ariaLabel={title}
|
||||
label={checked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderAdvancedOptions({
|
||||
fields,
|
||||
form,
|
||||
@@ -4065,11 +4231,11 @@ function ProviderAdvancedOptions({
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2 border-t border-border/45 pt-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border/45 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -4320,6 +4486,11 @@ function ProvidersSettings({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderRequestOptions
|
||||
providerName={provider.name}
|
||||
form={form}
|
||||
onChange={(value) => onChangeProviderForm(provider.name, value)}
|
||||
/>
|
||||
{supportsOauthAdvancedSettings ? (
|
||||
<ProviderAdvancedOptions
|
||||
fields={advancedFields}
|
||||
@@ -4445,6 +4616,11 @@ function ProvidersSettings({
|
||||
className="h-9 rounded-full text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
<ProviderRequestOptions
|
||||
providerName={provider.name}
|
||||
form={form}
|
||||
onChange={(value) => onChangeProviderForm(provider.name, value)}
|
||||
/>
|
||||
<ProviderAdvancedOptions
|
||||
fields={advancedFields}
|
||||
form={form}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
canonicalToolTrace,
|
||||
mergeToolProgressEvents,
|
||||
mergeToolProgressTraceLines,
|
||||
mergeUniqueToolTraceLines,
|
||||
} from "@/lib/tool-traces";
|
||||
import type { UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||
@@ -56,8 +57,15 @@ function canMergeAdjacentProgress(
|
||||
}
|
||||
|
||||
function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage {
|
||||
const traces = mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
|
||||
const toolEvents = mergeToolProgressEvents(previous.toolEvents, incoming.toolEvents ?? []);
|
||||
const traces = incoming.toolEvents?.length
|
||||
? mergeToolProgressTraceLines(
|
||||
messageTraces(previous),
|
||||
previous.toolEvents,
|
||||
messageTraces(incoming),
|
||||
incoming.toolEvents ?? [],
|
||||
)
|
||||
: mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
|
||||
const fileEdits = [...(previous.fileEdits ?? []), ...(incoming.fileEdits ?? [])];
|
||||
const media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function describeTraceLine(
|
||||
!!parsedUrl && /\b(fetch(?:ing|ed)?|read(?:ing)?|opened?|opening)\b/i.test(trimmed);
|
||||
|
||||
if (/search/i.test(name)) {
|
||||
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args || trimmed;
|
||||
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args;
|
||||
return {
|
||||
kind: "search",
|
||||
label: presentWebSearchAction(query, status, name === "x_search" ? "x" : "web"),
|
||||
|
||||
@@ -105,7 +105,7 @@ export function presentWebSearchAction(
|
||||
if (target === "x") {
|
||||
return queryTarget ? `${verb} X · ${queryTarget}` : `${verb} X`;
|
||||
}
|
||||
return queryTarget ? `${verb} ${queryTarget}` : verb;
|
||||
return queryTarget ? `${verb} ${queryTarget}` : `${verb} the web`;
|
||||
}
|
||||
|
||||
function mergeWebSearchRun(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useClient } from "@/providers/ClientProvider";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
import {
|
||||
mergeToolProgressEvents,
|
||||
mergeUniqueToolTraceLines,
|
||||
mergeToolProgressTraceLines,
|
||||
normalizeToolProgressEvents,
|
||||
toolTraceLinesFromEvents,
|
||||
} from "@/lib/tool-traces";
|
||||
@@ -1213,18 +1213,24 @@ export function useNanobotStream(
|
||||
: last.content
|
||||
? [last.content]
|
||||
: [];
|
||||
const mergedEvents = visibleStructuredEvents.length > 0
|
||||
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
|
||||
: last.toolEvents;
|
||||
const mergedLines = visibleStructuredEvents.length > 0
|
||||
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
|
||||
? mergeToolProgressTraceLines(
|
||||
previousTraces,
|
||||
last.toolEvents,
|
||||
structuredLines,
|
||||
visibleStructuredEvents,
|
||||
)
|
||||
: null;
|
||||
const merged: UIMessage = {
|
||||
...last,
|
||||
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
|
||||
traces: mergedLines ?? [...previousTraces, ...lines],
|
||||
content: mergedLines
|
||||
? mergedLines.traces[mergedLines.traces.length - 1]
|
||||
? mergedLines[mergedLines.length - 1]
|
||||
: lines[lines.length - 1],
|
||||
toolEvents: visibleStructuredEvents.length
|
||||
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
|
||||
: last.toolEvents,
|
||||
toolEvents: mergedEvents,
|
||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||
...turn,
|
||||
};
|
||||
|
||||
@@ -514,7 +514,15 @@
|
||||
"profile": "Profile",
|
||||
"extraHeaders": "Extra headers",
|
||||
"extraBody": "Extra body",
|
||||
"extraQuery": "Extra query"
|
||||
"extraQuery": "Extra query",
|
||||
"capabilityFastMode": "Fast mode",
|
||||
"capabilityFastModeHelp": "Use OpenAI's priority service tier for faster responses. This consumes credits faster.",
|
||||
"capabilityDeepSeekSearch": "DeepSeek web search",
|
||||
"capabilityDeepSeekSearchHelp": "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.",
|
||||
"capabilityXSearch": "X Search",
|
||||
"capabilityXSearchHelp": "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.",
|
||||
"capabilityOpenAISearch": "OpenAI web search",
|
||||
"capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat."
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
||||
|
||||
@@ -365,7 +365,15 @@
|
||||
"profile": "Perfil",
|
||||
"extraHeaders": "Cabeceras adicionales",
|
||||
"extraBody": "Cuerpo adicional",
|
||||
"extraQuery": "Parámetros de consulta adicionales"
|
||||
"extraQuery": "Parámetros de consulta adicionales",
|
||||
"capabilityFastMode": "Modo rápido",
|
||||
"capabilityFastModeHelp": "Usa el nivel de servicio prioritario de OpenAI para responder más rápido. Esto consume créditos más deprisa.",
|
||||
"capabilityDeepSeekSearch": "Búsqueda web de DeepSeek",
|
||||
"capabilityDeepSeekSearchHelp": "Permite que DeepSeek V4 Flash busque en la web mediante Responses API. La actividad de búsqueda aparece en el chat.",
|
||||
"capabilityXSearch": "Búsqueda en X",
|
||||
"capabilityXSearchHelp": "Permite que los modelos Grok compatibles usen X Search alojado por xAI. La actividad de búsqueda aparece en el chat.",
|
||||
"capabilityOpenAISearch": "Búsqueda web de OpenAI",
|
||||
"capabilityOpenAISearchHelp": "Permite que los modelos compatibles con Responses API busquen en la web. La actividad de búsqueda aparece en el chat."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Seleccionar proveedor",
|
||||
|
||||
@@ -365,7 +365,15 @@
|
||||
"profile": "Profil",
|
||||
"extraHeaders": "En-têtes supplémentaires",
|
||||
"extraBody": "Corps supplémentaire",
|
||||
"extraQuery": "Paramètres de requête supplémentaires"
|
||||
"extraQuery": "Paramètres de requête supplémentaires",
|
||||
"capabilityFastMode": "Mode rapide",
|
||||
"capabilityFastModeHelp": "Utilise le niveau de service prioritaire d’OpenAI pour accélérer les réponses. Les crédits sont consommés plus rapidement.",
|
||||
"capabilityDeepSeekSearch": "Recherche web DeepSeek",
|
||||
"capabilityDeepSeekSearchHelp": "Permet à DeepSeek V4 Flash de rechercher sur le web via l’API Responses. L’activité de recherche apparaît dans le chat.",
|
||||
"capabilityXSearch": "Recherche sur X",
|
||||
"capabilityXSearchHelp": "Permet aux modèles Grok compatibles d’utiliser X Search hébergé par xAI. L’activité de recherche apparaît dans le chat.",
|
||||
"capabilityOpenAISearch": "Recherche web OpenAI",
|
||||
"capabilityOpenAISearchHelp": "Permet aux modèles compatibles avec l’API Responses de rechercher sur le web. L’activité de recherche apparaît dans le chat."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Choisir un fournisseur",
|
||||
|
||||
@@ -365,7 +365,15 @@
|
||||
"profile": "Profil",
|
||||
"extraHeaders": "Header tambahan",
|
||||
"extraBody": "Body tambahan",
|
||||
"extraQuery": "Parameter kueri tambahan"
|
||||
"extraQuery": "Parameter kueri tambahan",
|
||||
"capabilityFastMode": "Mode cepat",
|
||||
"capabilityFastModeHelp": "Gunakan tingkat layanan prioritas OpenAI agar respons lebih cepat. Kredit akan terpakai lebih cepat.",
|
||||
"capabilityDeepSeekSearch": "Pencarian web DeepSeek",
|
||||
"capabilityDeepSeekSearchHelp": "Izinkan DeepSeek V4 Flash mencari di web melalui Responses API. Aktivitas pencarian ditampilkan di chat.",
|
||||
"capabilityXSearch": "Pencarian X",
|
||||
"capabilityXSearchHelp": "Izinkan model Grok yang didukung menggunakan X Search yang dihosting xAI. Aktivitas pencarian ditampilkan di chat.",
|
||||
"capabilityOpenAISearch": "Pencarian web OpenAI",
|
||||
"capabilityOpenAISearchHelp": "Izinkan model yang kompatibel dengan Responses API mencari di web. Aktivitas pencarian ditampilkan di chat."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Pilih penyedia",
|
||||
|
||||
@@ -365,7 +365,15 @@
|
||||
"profile": "プロファイル",
|
||||
"extraHeaders": "追加ヘッダー",
|
||||
"extraBody": "追加ボディ",
|
||||
"extraQuery": "追加クエリ"
|
||||
"extraQuery": "追加クエリ",
|
||||
"capabilityFastMode": "高速モード",
|
||||
"capabilityFastModeHelp": "OpenAI の優先サービス階層を使用して応答を高速化します。クレジットの消費も速くなります。",
|
||||
"capabilityDeepSeekSearch": "DeepSeek ウェブ検索",
|
||||
"capabilityDeepSeekSearchHelp": "DeepSeek V4 Flash が Responses API 経由でウェブを検索できるようにします。検索状況はチャットに表示されます。",
|
||||
"capabilityXSearch": "X 検索",
|
||||
"capabilityXSearchHelp": "対応する Grok モデルが xAI ホストの X Search を使用できるようにします。検索状況はチャットに表示されます。",
|
||||
"capabilityOpenAISearch": "OpenAI ウェブ検索",
|
||||
"capabilityOpenAISearchHelp": "Responses API 対応モデルがウェブを検索できるようにします。検索状況はチャットに表示されます。"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "プロバイダーを選択",
|
||||
|
||||
@@ -365,7 +365,15 @@
|
||||
"profile": "프로필",
|
||||
"extraHeaders": "추가 헤더",
|
||||
"extraBody": "추가 본문",
|
||||
"extraQuery": "추가 쿼리"
|
||||
"extraQuery": "추가 쿼리",
|
||||
"capabilityFastMode": "고속 모드",
|
||||
"capabilityFastModeHelp": "OpenAI 우선 서비스 등급을 사용해 더 빠르게 응답합니다. 크레딧도 더 빠르게 소모됩니다.",
|
||||
"capabilityDeepSeekSearch": "DeepSeek 웹 검색",
|
||||
"capabilityDeepSeekSearchHelp": "DeepSeek V4 Flash가 Responses API를 통해 웹을 검색하도록 허용합니다. 검색 활동은 채팅에 표시됩니다.",
|
||||
"capabilityXSearch": "X 검색",
|
||||
"capabilityXSearchHelp": "지원되는 Grok 모델이 xAI 호스팅 X Search를 사용하도록 허용합니다. 검색 활동은 채팅에 표시됩니다.",
|
||||
"capabilityOpenAISearch": "OpenAI 웹 검색",
|
||||
"capabilityOpenAISearchHelp": "Responses API 호환 모델이 웹을 검색하도록 허용합니다. 검색 활동은 채팅에 표시됩니다."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "제공자 선택",
|
||||
|
||||
@@ -514,7 +514,15 @@
|
||||
"profile": "Perfil",
|
||||
"extraHeaders": "Cabeçalhos adicionais",
|
||||
"extraBody": "Corpo adicional",
|
||||
"extraQuery": "Parâmetros de consulta adicionais"
|
||||
"extraQuery": "Parâmetros de consulta adicionais",
|
||||
"capabilityFastMode": "Modo rápido",
|
||||
"capabilityFastModeHelp": "Usa o nível de serviço prioritário da OpenAI para respostas mais rápidas. Isso consome créditos mais rapidamente.",
|
||||
"capabilityDeepSeekSearch": "Pesquisa web do DeepSeek",
|
||||
"capabilityDeepSeekSearchHelp": "Permite que o DeepSeek V4 Flash pesquise na web pela Responses API. A atividade de pesquisa aparece no chat.",
|
||||
"capabilityXSearch": "Pesquisa no X",
|
||||
"capabilityXSearchHelp": "Permite que modelos Grok compatíveis usem o X Search hospedado pela xAI. A atividade de pesquisa aparece no chat.",
|
||||
"capabilityOpenAISearch": "Pesquisa web da OpenAI",
|
||||
"capabilityOpenAISearchHelp": "Permite que modelos compatíveis com a Responses API pesquisem na web. A atividade de pesquisa aparece no chat."
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso."
|
||||
|
||||
@@ -365,7 +365,15 @@
|
||||
"profile": "Hồ sơ",
|
||||
"extraHeaders": "Header bổ sung",
|
||||
"extraBody": "Body bổ sung",
|
||||
"extraQuery": "Tham số truy vấn bổ sung"
|
||||
"extraQuery": "Tham số truy vấn bổ sung",
|
||||
"capabilityFastMode": "Chế độ nhanh",
|
||||
"capabilityFastModeHelp": "Dùng tầng dịch vụ ưu tiên của OpenAI để phản hồi nhanh hơn. Tín dụng cũng sẽ được tiêu thụ nhanh hơn.",
|
||||
"capabilityDeepSeekSearch": "Tìm kiếm web DeepSeek",
|
||||
"capabilityDeepSeekSearchHelp": "Cho phép DeepSeek V4 Flash tìm kiếm trên web qua Responses API. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện.",
|
||||
"capabilityXSearch": "Tìm kiếm X",
|
||||
"capabilityXSearchHelp": "Cho phép các mô hình Grok được hỗ trợ dùng X Search do xAI lưu trữ. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện.",
|
||||
"capabilityOpenAISearch": "Tìm kiếm web OpenAI",
|
||||
"capabilityOpenAISearchHelp": "Cho phép các mô hình tương thích với Responses API tìm kiếm trên web. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện."
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Chọn nhà cung cấp",
|
||||
|
||||
@@ -514,7 +514,15 @@
|
||||
"profile": "配置档案",
|
||||
"extraHeaders": "额外请求头",
|
||||
"extraBody": "额外请求体",
|
||||
"extraQuery": "额外查询参数"
|
||||
"extraQuery": "额外查询参数",
|
||||
"capabilityFastMode": "Fast 模式",
|
||||
"capabilityFastModeHelp": "通过 OpenAI 优先服务层获取更快响应,会更快消耗额度。",
|
||||
"capabilityDeepSeekSearch": "DeepSeek 联网搜索",
|
||||
"capabilityDeepSeekSearchHelp": "允许 DeepSeek V4 Flash 通过 Responses API 搜索网络,并在对话中展示搜索过程。",
|
||||
"capabilityXSearch": "X 搜索",
|
||||
"capabilityXSearchHelp": "允许受支持的 Grok 模型使用 xAI 托管的 X Search,并在对话中展示搜索过程。",
|
||||
"capabilityOpenAISearch": "OpenAI 联网搜索",
|
||||
"capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||
|
||||
@@ -365,7 +365,15 @@
|
||||
"profile": "設定檔",
|
||||
"extraHeaders": "額外請求標頭",
|
||||
"extraBody": "額外請求本文",
|
||||
"extraQuery": "額外查詢參數"
|
||||
"extraQuery": "額外查詢參數",
|
||||
"capabilityFastMode": "Fast 模式",
|
||||
"capabilityFastModeHelp": "透過 OpenAI 優先服務層取得更快回應,也會更快消耗額度。",
|
||||
"capabilityDeepSeekSearch": "DeepSeek 網路搜尋",
|
||||
"capabilityDeepSeekSearchHelp": "允許 DeepSeek V4 Flash 透過 Responses API 搜尋網路,並在對話中顯示搜尋過程。",
|
||||
"capabilityXSearch": "X 搜尋",
|
||||
"capabilityXSearchHelp": "允許支援的 Grok 模型使用 xAI 託管的 X Search,並在對話中顯示搜尋過程。",
|
||||
"capabilityOpenAISearch": "OpenAI 網路搜尋",
|
||||
"capabilityOpenAISearchHelp": "允許相容 Responses API 的模型搜尋網路,並在對話中顯示搜尋過程。"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "選擇供應商",
|
||||
|
||||
@@ -16,7 +16,10 @@ export function formatToolCallTrace(call: unknown): string | null {
|
||||
if (!name) return null;
|
||||
const args = item.function?.arguments ?? item.arguments;
|
||||
if (typeof args === "string" && args.trim()) return `${name}(${args})`;
|
||||
if (args && typeof args === "object") return `${name}(${JSON.stringify(args)})`;
|
||||
if (args && typeof args === "object") {
|
||||
const serialized = JSON.stringify(args);
|
||||
return serialized === "{}" || serialized === "[]" ? `${name}()` : `${name}(${serialized})`;
|
||||
}
|
||||
return `${name}()`;
|
||||
}
|
||||
|
||||
@@ -116,3 +119,24 @@ export function mergeUniqueToolTraceLines(
|
||||
}
|
||||
return { traces, added };
|
||||
}
|
||||
|
||||
export function mergeToolProgressTraceLines(
|
||||
previousTraces: string[],
|
||||
previousEvents: ToolProgressEvent[] | undefined,
|
||||
incomingTraces: string[],
|
||||
incomingEvents: ToolProgressEvent[],
|
||||
): string[] {
|
||||
const mergedEvents = mergeToolProgressEvents(previousEvents, incomingEvents);
|
||||
const candidates = mergeUniqueToolTraceLines(previousTraces, incomingTraces).traces;
|
||||
const eventTraceKeys = new Set([
|
||||
...toolTraceLinesFromEvents(previousEvents),
|
||||
...toolTraceLinesFromEvents(incomingEvents),
|
||||
].map(canonicalToolTrace));
|
||||
const nonEventTraces = candidates.filter(
|
||||
(line) => !eventTraceKeys.has(canonicalToolTrace(line)),
|
||||
);
|
||||
return mergeUniqueToolTraceLines(
|
||||
nonEventTraces,
|
||||
toolTraceLinesFromEvents(mergedEvents),
|
||||
).traces;
|
||||
}
|
||||
|
||||
@@ -1165,12 +1165,13 @@ describe("AgentActivityCluster", () => {
|
||||
id: "search-start",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: line,
|
||||
traces: [line],
|
||||
content: "web_search()",
|
||||
traces: ["web_search()"],
|
||||
toolEvents: [{
|
||||
phase: "start",
|
||||
call_id: "hosted-search-1",
|
||||
name: "web_search",
|
||||
arguments: { query: "site:linkedin.com/company Evomap startup" },
|
||||
arguments: {},
|
||||
}],
|
||||
createdAt: 1,
|
||||
},
|
||||
@@ -1182,6 +1183,7 @@ describe("AgentActivityCluster", () => {
|
||||
traces: [line],
|
||||
toolEvents: [{
|
||||
phase: "error",
|
||||
call_id: "hosted-search-1",
|
||||
name: "web_search",
|
||||
arguments: { query: "site:linkedin.com/company Evomap startup" },
|
||||
error: "Search provider rate limited the request",
|
||||
|
||||
@@ -580,7 +580,7 @@ describe("webui API helpers", () => {
|
||||
await updateProviderSettings("tok", {
|
||||
provider: "xai_grok",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
extraBody: '{"service_tier":"priority"}',
|
||||
extraBody: '{"tools":[]}',
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
@@ -590,7 +590,7 @@ describe("webui API helpers", () => {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
extraBody: '{"service_tier":"priority"}',
|
||||
extraBody: '{"tools":[]}',
|
||||
})),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -3101,6 +3101,200 @@ describe("SettingsView Apps catalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps provider request switches to raw extraBody fields", async () => {
|
||||
const base = settingsPayload();
|
||||
const providers: SettingsPayload["providers"] = [
|
||||
{
|
||||
name: "xai_grok",
|
||||
label: "xAI Grok",
|
||||
configured: true,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
oauth_account: "grok@example.com",
|
||||
oauth_login_supported: true,
|
||||
advanced_fields: ["extra_body", "proxy"],
|
||||
extra_body: null,
|
||||
},
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: true,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
oauth_account: "codex@example.com",
|
||||
oauth_login_supported: true,
|
||||
advanced_fields: ["extra_body", "proxy"],
|
||||
extra_body: null,
|
||||
},
|
||||
{
|
||||
name: "deepseek",
|
||||
label: "DeepSeek",
|
||||
configured: true,
|
||||
api_key_required: true,
|
||||
api_key_hint: "deep••••test",
|
||||
api_base: "https://api.deepseek.com",
|
||||
advanced_fields: ["extra_body"],
|
||||
extra_body: null,
|
||||
},
|
||||
{
|
||||
name: "openai",
|
||||
label: "OpenAI",
|
||||
configured: true,
|
||||
api_key_required: true,
|
||||
api_key_hint: "sk-••••test",
|
||||
api_base: "https://api.openai.com/v1",
|
||||
api_type: "auto",
|
||||
advanced_fields: ["api_type", "extra_body"],
|
||||
extra_body: null,
|
||||
},
|
||||
];
|
||||
const payload: SettingsPayload = { ...base, providers };
|
||||
const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => {
|
||||
const [input] = args;
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload);
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
|
||||
const xSearch = screen.getByRole("switch", { name: "X Search" });
|
||||
expect(xSearch).toHaveAttribute("aria-checked", "true");
|
||||
fireEvent.click(xSearch);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/update?provider=xai_grok",
|
||||
expect.anything(),
|
||||
));
|
||||
await waitFor(() => expect(
|
||||
screen.getByRole("button", { name: "Save provider" }),
|
||||
).toBeEnabled());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Fast mode" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/update?provider=openai_codex",
|
||||
expect.anything(),
|
||||
));
|
||||
await waitFor(() => expect(
|
||||
screen.getByRole("button", { name: "Save provider" }),
|
||||
).toBeEnabled());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^DeepSeek/ }));
|
||||
expect(screen.getByText(/DeepSeek V4 Flash/)).toBeInTheDocument();
|
||||
const deepSeekSearch = screen.getByRole("switch", { name: "DeepSeek web search" });
|
||||
expect(deepSeekSearch).toHaveAttribute("aria-checked", "true");
|
||||
fireEvent.click(deepSeekSearch);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
await waitFor(() => expect(
|
||||
screen.queryByRole("switch", { name: "DeepSeek web search" }),
|
||||
).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^OpenAI https:/ }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "OpenAI web search" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
await waitFor(() => expect(
|
||||
screen.queryByRole("switch", { name: "OpenAI web search" }),
|
||||
).not.toBeInTheDocument());
|
||||
|
||||
await waitFor(() => {
|
||||
const requestUpdates = fetchMock.mock.calls
|
||||
.filter(([input]) => String(input).startsWith("/api/settings/provider/update?"))
|
||||
.map(([input, init]) => {
|
||||
const provider = new URLSearchParams(String(input).split("?")[1]).get("provider");
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
const values = JSON.parse(decodeURIComponent(
|
||||
headers["X-Nanobot-Provider-Values"],
|
||||
)) as { apiType?: string; extraBody?: string };
|
||||
return [provider, {
|
||||
...(values.apiType ? { apiType: values.apiType } : {}),
|
||||
extraBody: JSON.parse(values.extraBody ?? "{}"),
|
||||
}] as const;
|
||||
});
|
||||
expect(requestUpdates).toEqual([
|
||||
["xai_grok", { extraBody: { tools: [] } }],
|
||||
["openai_codex", { extraBody: { service_tier: "priority" } }],
|
||||
["deepseek", { extraBody: { tools: [] } }],
|
||||
["openai", {
|
||||
apiType: "responses",
|
||||
extraBody: { tools: [{ type: "web_search" }] },
|
||||
}],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes and removes versioned web search tools without losing raw settings", async () => {
|
||||
const base = settingsPayload();
|
||||
const payload: SettingsPayload = {
|
||||
...base,
|
||||
providers: [{
|
||||
name: "openai",
|
||||
label: "OpenAI",
|
||||
configured: true,
|
||||
api_key_required: true,
|
||||
api_key_hint: "sk-••••test",
|
||||
api_base: "https://api.openai.com/v1",
|
||||
api_type: "auto",
|
||||
advanced_fields: ["api_type", "extra_body"],
|
||||
extra_body: {
|
||||
metadata: { owner: "legacy-config" },
|
||||
tools: [
|
||||
{ type: "web_search_preview", search_context_size: "medium" },
|
||||
{ type: "file_search", vector_store_ids: ["vs_legacy"] },
|
||||
],
|
||||
},
|
||||
}],
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload);
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^OpenAI https:/ }));
|
||||
const searchSwitch = screen.getByRole("switch", { name: "OpenAI web search" });
|
||||
expect(searchSwitch).toHaveAttribute("aria-checked", "true");
|
||||
fireEvent.click(searchSwitch);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const updateCall = fetchMock.mock.calls.find(
|
||||
([input]) => String(input).startsWith("/api/settings/provider/update?"),
|
||||
);
|
||||
expect(updateCall).toBeTruthy();
|
||||
const headers = updateCall?.[1]?.headers as Record<string, string>;
|
||||
const values = JSON.parse(decodeURIComponent(
|
||||
headers["X-Nanobot-Provider-Values"],
|
||||
)) as { extraBody: string };
|
||||
expect(JSON.parse(values.extraBody)).toEqual({
|
||||
metadata: { owner: "legacy-config" },
|
||||
tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a custom provider with folded advanced request settings", async () => {
|
||||
const base = settingsPayload();
|
||||
let payload: SettingsPayload = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canonicalToolTrace,
|
||||
mergeToolProgressTraceLines,
|
||||
mergeUniqueToolTraceLines,
|
||||
} from "@/lib/tool-traces";
|
||||
|
||||
@@ -26,4 +27,18 @@ describe("tool trace identity", () => {
|
||||
added: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an empty streaming placeholder when hosted search arguments arrive", () => {
|
||||
expect(mergeToolProgressTraceLines(
|
||||
["web_search()"],
|
||||
[{ phase: "start", call_id: "ws-1", name: "web_search", arguments: {} }],
|
||||
['web_search({"query":"nanobot news"})'],
|
||||
[{
|
||||
phase: "end",
|
||||
call_id: "ws-1",
|
||||
name: "web_search",
|
||||
arguments: { query: "nanobot news" },
|
||||
}],
|
||||
)).toEqual(['web_search({"query":"nanobot news"})']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ function describeTrace(line: string, status: GenericToolStatus = "done") {
|
||||
}
|
||||
|
||||
describe("trace activity semantics", () => {
|
||||
it("uses readable copy when a hosted search query is unavailable", () => {
|
||||
expect(describeTrace("web_search()").label).toBe("Searched the web");
|
||||
});
|
||||
|
||||
it.each([
|
||||
['web_search({"query":"nanobot latest release"})', "done", "Searched nanobot latest release", ""],
|
||||
['web_fetch({"url":"https://example.com/docs?token=private"})', "done", "Read", "example.com/docs"],
|
||||
|
||||
@@ -610,6 +610,51 @@ describe("useNanobotStream", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces a hosted search placeholder when its query arrives", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-hosted-search", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-hosted-search", {
|
||||
event: "message",
|
||||
chat_id: "chat-hosted-search",
|
||||
text: "web_search()",
|
||||
kind: "tool_hint",
|
||||
tool_events: [{
|
||||
phase: "start",
|
||||
call_id: "ws-1",
|
||||
name: "web_search",
|
||||
arguments: {},
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-hosted-search", {
|
||||
event: "message",
|
||||
chat_id: "chat-hosted-search",
|
||||
text: "",
|
||||
kind: "progress",
|
||||
tool_events: [{
|
||||
phase: "end",
|
||||
call_id: "ws-1",
|
||||
name: "web_search",
|
||||
arguments: { query: "nanobot news" },
|
||||
result: { status: "completed" },
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].traces).toEqual([
|
||||
'web_search({"query":"nanobot news"})',
|
||||
]);
|
||||
expect(result.current.messages[0].toolEvents).toMatchObject([{
|
||||
phase: "end",
|
||||
call_id: "ws-1",
|
||||
arguments: { query: "nanobot news" },
|
||||
}]);
|
||||
});
|
||||
|
||||
it("keeps phase updates when a tool event trace line is deduped", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {
|
||||
|
||||
Reference in New Issue
Block a user