diff --git a/packages/core/src/llm/dashscope.ts b/packages/core/src/llm/dashscope.ts index fe83d218..fbaffc4f 100644 --- a/packages/core/src/llm/dashscope.ts +++ b/packages/core/src/llm/dashscope.ts @@ -112,7 +112,7 @@ export class DashScopeProvider extends OpenAIProvider { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authorization }, body: JSON.stringify(body), - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(this.imageGenerationTimeoutMs), }); if (!res.ok) { @@ -169,7 +169,7 @@ export class DashScopeProvider extends OpenAIProvider { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authorization }, body: JSON.stringify(body), - signal: AbortSignal.timeout(60_000), + signal: AbortSignal.timeout(this.ttsTimeoutMs), }); if (!res.ok) { diff --git a/packages/core/src/llm/fireworks.ts b/packages/core/src/llm/fireworks.ts index b2acabd3..4db0886b 100644 --- a/packages/core/src/llm/fireworks.ts +++ b/packages/core/src/llm/fireworks.ts @@ -64,7 +64,7 @@ export class FireworksProvider extends OpenAIProvider { Authorization: authorization, }, body: JSON.stringify(body), - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(this.imageGenerationTimeoutMs), }); if (!res.ok) { diff --git a/packages/core/src/llm/markus-provider.ts b/packages/core/src/llm/markus-provider.ts index b81412d3..df052f1f 100644 --- a/packages/core/src/llm/markus-provider.ts +++ b/packages/core/src/llm/markus-provider.ts @@ -303,6 +303,13 @@ export class MarkusProvider implements MultiModalProviderInterface { private maxTokens?: number; private chatTimeoutMs: number; private streamTimeoutMs: number; + /** Generative-media client timeouts; each independently configurable per + * provider (defaults match the historical hardcoded ceilings). */ + private imageGenerationTimeoutMs: number; + private ttsTimeoutMs: number; + private sttTimeoutMs: number; + private videoGenerationTimeoutMs: number; + private decisionTimeoutMs: number; private cuCache = new CUCache(); /** Hub-served geo-aware model catalog URL. */ private modelsUrl = ''; @@ -330,6 +337,14 @@ export class MarkusProvider implements MultiModalProviderInterface { // Stream idle is independent of chat timeoutMs — never inherit a lower chat // timeout (e.g. 90s) or long reasoning / sparse SSE gaps abort mid-reply. this.streamTimeoutMs = config?.streamTimeoutMs ?? STREAM_TIMEOUT_MS; + // Generative-media client timeouts; each independently configurable per + // provider. Default: generous 10min — media synthesis through OpenRouter + // and self-hosted servers can take minutes on first use. + this.imageGenerationTimeoutMs = config?.imageGenerationTimeoutMs ?? 600_000; + this.ttsTimeoutMs = config?.ttsTimeoutMs ?? 600_000; + this.sttTimeoutMs = config?.sttTimeoutMs ?? 600_000; + this.videoGenerationTimeoutMs = config?.videoGenerationTimeoutMs ?? 600_000; + this.decisionTimeoutMs = config?.decisionTimeoutMs ?? 60_000; this.applyRetryConfig(config); this.modelsUrl = config?.modelsUrl ?? process.env['MARKUS_MODELS_URL'] ?? ''; this.hubUrl = config?.hubUrl ?? process.env['MARKUS_HUB_URL'] ?? ''; @@ -361,6 +376,11 @@ export class MarkusProvider implements MultiModalProviderInterface { if (config.hubToken !== undefined) this.hubToken = config.hubToken; if (config.timeoutMs) this.chatTimeoutMs = config.timeoutMs; if (config.streamTimeoutMs) this.streamTimeoutMs = config.streamTimeoutMs; + if (config.imageGenerationTimeoutMs !== undefined) this.imageGenerationTimeoutMs = config.imageGenerationTimeoutMs; + if (config.ttsTimeoutMs !== undefined) this.ttsTimeoutMs = config.ttsTimeoutMs; + if (config.sttTimeoutMs !== undefined) this.sttTimeoutMs = config.sttTimeoutMs; + if (config.videoGenerationTimeoutMs !== undefined) this.videoGenerationTimeoutMs = config.videoGenerationTimeoutMs; + if (config.decisionTimeoutMs !== undefined) this.decisionTimeoutMs = config.decisionTimeoutMs; // Retry knobs follow the same precedence on re-configure (env re-read too). this.applyRetryConfig(config); } @@ -1549,7 +1569,7 @@ export class MarkusProvider implements MultiModalProviderInterface { 'X-Title': 'Markus', }, body: JSON.stringify(body), - signal: AbortSignal.timeout(60_000), + signal: AbortSignal.timeout(this.decisionTimeoutMs), }); if (!res.ok) { @@ -1608,7 +1628,7 @@ export class MarkusProvider implements MultiModalProviderInterface { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: this.bearerOpenRouter() }, body: JSON.stringify(body), - signal: AbortSignal.timeout(180_000), + signal: AbortSignal.timeout(this.imageGenerationTimeoutMs), }); if (!res.ok) { const errText = await res.text(); @@ -1705,7 +1725,7 @@ export class MarkusProvider implements MultiModalProviderInterface { headers: { 'Content-Type': 'application/json', Authorization: this.bearerOpenRouter() }, body: JSON.stringify(body), // Long narration + MiniMax/Deepgram synthesis often exceeds 60s. - signal: AbortSignal.timeout(180_000), + signal: AbortSignal.timeout(this.ttsTimeoutMs), }); if (!res.ok) { const errText = await res.text(); @@ -1758,7 +1778,7 @@ export class MarkusProvider implements MultiModalProviderInterface { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: this.bearerOpenRouter() }, body: JSON.stringify(body), - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(this.sttTimeoutMs), }); if (!res.ok) { const errText = await res.text(); @@ -1823,7 +1843,7 @@ export class MarkusProvider implements MultiModalProviderInterface { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: this.bearerOpenRouter() }, body: JSON.stringify(body), - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(this.videoGenerationTimeoutMs), }); if (!createRes.ok) { const errText = await createRes.text(); diff --git a/packages/core/src/llm/minimax.ts b/packages/core/src/llm/minimax.ts index 3ad85d26..6c5a8f80 100644 --- a/packages/core/src/llm/minimax.ts +++ b/packages/core/src/llm/minimax.ts @@ -126,7 +126,7 @@ export class MiniMaxProvider extends OpenAIProvider { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authorization }, body: JSON.stringify(body), - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(this.imageGenerationTimeoutMs), }); if (!res.ok) { @@ -177,7 +177,7 @@ export class MiniMaxProvider extends OpenAIProvider { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authorization }, body: JSON.stringify(body), - signal: AbortSignal.timeout(180_000), + signal: AbortSignal.timeout(this.ttsTimeoutMs), }); if (!res.ok) { @@ -224,7 +224,7 @@ export class MiniMaxProvider extends OpenAIProvider { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authorization }, body: JSON.stringify(body), - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(this.videoGenerationTimeoutMs), }); if (!createRes.ok) { diff --git a/packages/core/src/llm/openai.ts b/packages/core/src/llm/openai.ts index a0f88183..fcce29bd 100644 --- a/packages/core/src/llm/openai.ts +++ b/packages/core/src/llm/openai.ts @@ -102,6 +102,11 @@ export class OpenAIProvider implements MultiModalProviderInterface { protected maxTokens: number; protected chatTimeoutMs: number; protected streamTimeoutMs: number; + protected imageGenerationTimeoutMs: number; + protected ttsTimeoutMs: number; + protected sttTimeoutMs: number; + protected videoGenerationTimeoutMs: number; + protected decisionTimeoutMs: number; protected tokenResolver?: TokenResolver; /** Explicit decisions endpoint; when unset it is resolved from `baseUrl`. */ protected decisionsUrl?: string; @@ -122,6 +127,16 @@ export class OpenAIProvider implements MultiModalProviderInterface { this.chatTimeoutMs = config?.timeoutMs ?? 90_000; // Idle gap between chunks (reset on data). Independent of chat timeoutMs. this.streamTimeoutMs = config?.streamTimeoutMs ?? 180_000; + // Generative media (image/video/audio) is naturally slower than chat and + // each costs real compute — give every modality an independently configured + // client timeout (per provider), instead of one hardcoded ceiling for all. + // Default: generous 10min — local/self-hosted servers (diffusers, vLLM) + // must cold-load weights on the first request and can take minutes. + this.imageGenerationTimeoutMs = config?.imageGenerationTimeoutMs ?? 600_000; + this.ttsTimeoutMs = config?.ttsTimeoutMs ?? 600_000; + this.sttTimeoutMs = config?.sttTimeoutMs ?? 600_000; + this.videoGenerationTimeoutMs = config?.videoGenerationTimeoutMs ?? 600_000; + this.decisionTimeoutMs = config?.decisionTimeoutMs ?? 60_000; this.decisionsUrl = config?.decisionsUrl; this.tokenResolver = tokenResolver; } @@ -133,6 +148,11 @@ export class OpenAIProvider implements MultiModalProviderInterface { if (config.maxTokens) this.maxTokens = config.maxTokens; if (config.timeoutMs) this.chatTimeoutMs = config.timeoutMs; if (config.streamTimeoutMs) this.streamTimeoutMs = config.streamTimeoutMs; + if (config.imageGenerationTimeoutMs !== undefined) this.imageGenerationTimeoutMs = config.imageGenerationTimeoutMs; + if (config.ttsTimeoutMs !== undefined) this.ttsTimeoutMs = config.ttsTimeoutMs; + if (config.sttTimeoutMs !== undefined) this.sttTimeoutMs = config.sttTimeoutMs; + if (config.videoGenerationTimeoutMs !== undefined) this.videoGenerationTimeoutMs = config.videoGenerationTimeoutMs; + if (config.decisionTimeoutMs !== undefined) this.decisionTimeoutMs = config.decisionTimeoutMs; if (config.decisionsUrl) this.decisionsUrl = config.decisionsUrl; } @@ -588,7 +608,7 @@ export class OpenAIProvider implements MultiModalProviderInterface { 'X-Title': 'Markus', }, body: JSON.stringify(body), - signal: AbortSignal.timeout(60_000), + signal: AbortSignal.timeout(this.decisionTimeoutMs), }); if (!res.ok) { @@ -630,7 +650,7 @@ export class OpenAIProvider implements MultiModalProviderInterface { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authorization }, body: JSON.stringify(body), - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(this.imageGenerationTimeoutMs), }); if (!res.ok) { @@ -675,7 +695,7 @@ export class OpenAIProvider implements MultiModalProviderInterface { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authorization }, body: JSON.stringify(body), - signal: AbortSignal.timeout(180_000), + signal: AbortSignal.timeout(this.ttsTimeoutMs), }); if (!res.ok) { @@ -707,7 +727,7 @@ export class OpenAIProvider implements MultiModalProviderInterface { method: 'POST', headers: { Authorization: authorization }, body: formData, - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(this.sttTimeoutMs), }); if (!res.ok) { diff --git a/packages/core/src/llm/router.ts b/packages/core/src/llm/router.ts index 29d3c852..1e1a9f63 100644 --- a/packages/core/src/llm/router.ts +++ b/packages/core/src/llm/router.ts @@ -1333,8 +1333,16 @@ export class LLMRouter { * Resolve a provider instance for a non-text modality (image_generation, audio_tts, etc.). * Returns the provider and the assigned model name WITHOUT mutating provider state. * The caller is responsible for passing `model` into the API call options. + * + * For non-text capabilities this NEVER falls back to the global text routing + * default model id (deepseek/gpt-4o/…): posting a chat id to an image/tts/stt + * endpoint yields a confusing upstream 404 ("model not found" / "not served for + * this capability"). Unassigned capabilities resolve to the provider only; the + * provider's own modality-appropriate default (or an explicit per-call model) + * is used instead. */ resolveModalityProvider(capabilityType: ModelCapabilityType): { provider: MultiModalProviderInterface; model?: string } | undefined { + const isText = capabilityType === 'text'; const assignment = this._capabilityRouting.assignments[capabilityType]; if (assignment) { const provider = this.providers.get(assignment.provider); @@ -1349,17 +1357,51 @@ export class LLMRouter { } } - // Fallback: try routingDefaultModel, then defaultProvider + // Fallback: try routingDefaultModel, then defaultProvider. Only text routing + // carries the default text model id; non-text capabilities must NOT reuse it + // (see class doc above) — except when the routing default model provably + // serves the capability (declared in its catalog entry), so a sensible + // media default like "image-01" still resolves instead of posting a chat id + // like "deepseek/deepseek-v4-flash-0731" to an image endpoint (upstream 404). if (this._routingDefaultModel) { const p = this.providers.get(this._routingDefaultModel.provider); if (p && this.isAvailable(this._routingDefaultModel.provider)) { - return { provider: p as MultiModalProviderInterface, model: this._routingDefaultModel.model }; + const model = isText || this.routingDefaultModelServesCapability(capabilityType) + ? this._routingDefaultModel.model + : undefined; + return { provider: p as MultiModalProviderInterface, ...(model ? { model } : {}) }; } } const p = this.providers.get(this.defaultProvider); return p && this.isAvailable(this.defaultProvider) ? { provider: p as MultiModalProviderInterface } : undefined; } + /** + * True when the routing default model's catalog entry declares the requested + * capability. Used to decide whether a non-text fallback may carry the model + * id — a text/chat model must never be POSTed to a media endpoint. + */ + private routingDefaultModelServesCapability(capabilityType: ModelCapabilityType): boolean { + const r = this._routingDefaultModel; + if (!r) return false; + try { + const entry = this.getProviderModels(r.provider).find(m => m.id === r.model); + if (!entry) return false; + switch (capabilityType) { + case 'image_generation': return !!entry.capabilities?.includes('imageGeneration'); + case 'audio_tts': return !!entry.capabilities?.includes('tts'); + case 'audio_stt': return !!entry.capabilities?.includes('stt'); + case 'video_generation': return !!entry.capabilities?.includes('videoGeneration'); + case 'decision': return !!entry.capabilities?.includes('decision'); + case 'image_recognition': + return !!entry.inputTypes?.includes('image') || !!entry.capabilities?.includes('vision'); + default: return false; + } + } catch { + return false; + } + } + /** * Look up any registered provider by name for one-shot tool overrides * (`provider=` + `model=` on generate_image / text_to_speech / …). diff --git a/packages/core/src/tools/multimodal.ts b/packages/core/src/tools/multimodal.ts index 8b28dc5b..29ed8964 100644 --- a/packages/core/src/tools/multimodal.ts +++ b/packages/core/src/tools/multimodal.ts @@ -238,6 +238,24 @@ function resolveEffectiveCandidates( }; } if (agentModel) { + // Qualified "provider/model" ids (e.g. "openai/gpt-image-1") must NOT be + // blindly stamped onto every candidate model — if the caller passes a + // chat id like "deepseek/deepseek-v4-flash-0731" for image_generation, we + // would otherwise POST a text model to an image endpoint and surface a + // confusing upstream 404 ("model not found"). Split the prefix into an + // explicit provider so the modality-support check below applies and the + // error names the real problem (provider does not serve this capability). + const slash = agentModel.indexOf('/'); + if (slash > 0) { + const prefix = agentModel.slice(0, slash); + const bare = agentModel.slice(slash + 1); + if ( + bare + && (ctx.resolveProvider?.(prefix) || candidates.some(c => c.name === prefix)) + ) { + return resolveEffectiveCandidates(candidates, prefix, bare, ctx, method); + } + } return { candidates: candidates.map(c => ({ ...c, model: agentModel })) }; } return { candidates }; @@ -865,7 +883,13 @@ export function createMultiModalTools(ctx: MultiModalToolsContext): AgentToolHan log.error(`Image generation failed on all ${candidates.length} provider(s)`); return toolErr( `Image generation failed (tried: ${tried}): ${lastError instanceof Error ? lastError.message : String(lastError)}`, - { hint: ROUTING_HINT, tried_models: candidates.map(c => c.model).filter(Boolean) }, + { + hint: + `${ROUTING_HINT} ` + + `If the error mentions "model not found" / "not served for this capability", the selected model is a ` + + `text/chat model, not an image model — pick from llm_get_capability_routing.usable_models.image_generation.`, + tried_models: candidates.map(c => c.model).filter(Boolean), + }, ); }, }, diff --git a/packages/core/src/tools/settings.ts b/packages/core/src/tools/settings.ts index ed61305f..5e37f988 100644 --- a/packages/core/src/tools/settings.ts +++ b/packages/core/src/tools/settings.ts @@ -85,6 +85,10 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler cost: m.cost, reasoning: m.reasoning, vision: m.inputTypes?.includes('image'), + // Explicit capability declaration (imageGeneration/tts/stt/…). + // Lets agents see at a glance which non-chat models a provider + // can actually serve — no id-guessing needed. + capabilities: m.capabilities ?? [], })) ?? [], }; }); @@ -212,6 +216,26 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler type: 'string', description: 'Default model ID (e.g. "deepseek-v4-flash")', }, + image_generation_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for image generation calls (default 600000 = 10min; raise for very slow local/self-hosted servers)', + }, + tts_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for text-to-speech calls (default 600000 = 10min)', + }, + stt_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for speech-to-text calls (default 600000 = 10min)', + }, + video_generation_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for video generation calls (default 600000 = 10min)', + }, + decision_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for decision calls (default 60000)', + }, }, required: ['name', 'model'], }, @@ -220,12 +244,14 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler const apiKey = args['api_key'] as string | undefined; const baseUrl = args['base_url'] as string | undefined; const model = args['model'] as string; + const mediaTimeouts = pickMediaTimeoutFields(args); try { ctx.llmRouter.registerProviderFromConfig(name, { provider: name as any, model, apiKey, baseUrl, + ...mediaTimeouts, }); if (ctx.persistConfig) { @@ -237,6 +263,7 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler ...(apiKey ? { apiKey } : {}), model, ...(baseUrl ? { baseUrl } : {}), + ...mediaTimeouts, enabled: true, }, }, @@ -280,6 +307,26 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler type: 'string', description: 'New model ID (optional)', }, + image_generation_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for image generation calls (default 600000 = 10min)', + }, + tts_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for text-to-speech calls (default 600000 = 10min)', + }, + stt_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for speech-to-text calls (default 600000 = 10min)', + }, + video_generation_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for video generation calls (default 600000 = 10min)', + }, + decision_timeout_ms: { + type: 'number', + description: 'Optional timeout in ms for decision calls (default 60000)', + }, }, required: ['provider'], }, @@ -288,6 +335,7 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler const apiKey = args['api_key'] as string | undefined; const baseUrl = args['base_url'] as string | undefined; const model = args['model'] as string | undefined; + const mediaTimeouts = pickMediaTimeoutFields(args); try { const provider = ctx.llmRouter.getProvider(providerName); if (!provider) { @@ -297,6 +345,7 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler if (model) configUpdate.model = model; if (apiKey) configUpdate.apiKey = apiKey; if (baseUrl !== undefined) configUpdate.baseUrl = baseUrl; + Object.assign(configUpdate, mediaTimeouts); provider.configure(configUpdate); if (ctx.persistConfig) { @@ -305,6 +354,7 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler if (apiKey) updates.apiKey = apiKey; if (model) updates.model = model; if (baseUrl !== undefined) updates.baseUrl = baseUrl || undefined; + Object.assign(updates, mediaTimeouts); ctx.persistConfig({ llm: { providers: { [providerName]: updates } } } as any); } catch (e) { log.warn('Failed to persist provider edit', { error: String(e) }); @@ -363,11 +413,27 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler type: 'boolean', description: 'Whether the model supports image input (optional)', }, + capabilities: { + type: 'array', + items: { + type: 'string', + enum: ['imageGeneration', 'vision', 'tts', 'stt', 'videoGeneration', 'decision'], + }, + description: + 'Explicit capability declaration for this model (OPTIONAL but recommended for non-chat models). ' + + 'Values: imageGeneration (text-to-image), vision (image input), tts, stt, videoGeneration, decision. ' + + 'When declared, capability routing trusts this list instead of guessing from the model id — ' + + 'use this for local/self-hosted models (Ollama, vLLM, diffusers servers) whose names do not match ' + + 'known commercial naming patterns.', + }, }, required: ['provider', 'id', 'name', 'context_window', 'max_output_tokens', 'cost_input', 'cost_output'], }, async execute(args: Record): Promise { const providerName = args['provider'] as string; + const declaredCaps = Array.isArray(args['capabilities']) + ? (args['capabilities'] as unknown[]).filter((c): c is string => typeof c === 'string') + : []; const modelDef = { id: args['id'] as string, name: args['name'] as string, @@ -380,6 +446,7 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler }, ...(args['reasoning'] ? { reasoning: true } : {}), ...(args['vision'] ? { inputTypes: ['text' as const, 'image' as const] } : { inputTypes: ['text' as const] }), + ...(declaredCaps.length > 0 ? { capabilities: declaredCaps } : {}), }; try { ctx.llmRouter.addCustomModel(providerName, modelDef); @@ -495,6 +562,13 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler type: 'string', description: 'Optional fallback model', }, + force: { + type: 'boolean', + description: + 'When true, bypass the model-name heuristic gate (e.g. a self-hosted image model whose id does not look ' + + 'like a commercial image model). Explicit capability declarations on the model (via llm_add_model ' + + 'capabilities=...) are still honored and preferred. Default false.', + }, }, required: ['capability_type', 'provider', 'model'], }, @@ -503,6 +577,7 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler const capabilityType = resolveCapabilityTypeArg(args, VALID); const provider = String(args['provider'] ?? '').trim(); const model = String(args['model'] ?? '').trim(); + const force = args['force'] === true; try { if (!capabilityType) { @@ -549,13 +624,24 @@ export function createSettingsTools(ctx: SettingsToolsContext): AgentToolHandler hint: `Call llm_get_capability_routing and pick from usable_models.${capabilityType}.`, }); } - const mismatch = detectModelCapabilityMismatch(model, capabilityType); - if (mismatch) { - return JSON.stringify({ - status: 'error', - error: mismatch, - hint: `Use llm_get_capability_routing.usable_models.${capabilityType} to find a suitable model.`, - }); + // Explicit capability declaration (via llm_add_model capabilities=) + // is authoritative: if the model advertises the tag, skip the name + // heuristic entirely — local/self-hosted ids (qwen-image-2.1, + // stable-diffusion-server, …) must not be judged by commercial + // naming patterns. Only fall back to the regex gate when (a) the + // model carries no declaration AND (b) the caller did not force. + const declared = findModelDeclaredCapabilities(ctx.llmRouter, provider, model); + if (!force && !declared) { + const mismatch = detectModelCapabilityMismatch(model, capabilityType); + if (mismatch) { + return JSON.stringify({ + status: 'error', + error: mismatch, + hint: `Use llm_get_capability_routing.usable_models.${capabilityType} to find a suitable model, ` + + `or declare capabilities on this model via llm_add_model (capabilities: ["${CAP_TAG_BY_TYPE[capabilityType] ?? 'imageGeneration'}"]), ` + + 'or pass force:true to override the name heuristic.', + }); + } } } @@ -804,6 +890,52 @@ const CAP_TAG_BY_TYPE: Partial> = { decision: 'decision', }; +/** + * Return the declared capability tags for a provider/model, or undefined when + * the model carries no explicit capability declaration. Model-level + * declarations (llm_add_model capabilities=...) are authoritative over the + * id-based naming heuristic — this is the single lookup used both for routing + * validation and for the usable_models listing, keeping the two in sync. + */ +function findModelDeclaredCapabilities( + router: LLMRouter, + provider: string, + model: string, +): string[] | undefined { + const settings = router.getEnhancedSettings(); + const p = settings.providers[provider]; + if (!p?.models?.length) return undefined; + const entry = p.models.find(m => m.id === model); + if (!entry?.capabilities?.length) return undefined; + return entry.capabilities; +} + +/** Map the snake_case media-timeout args (llm_add_provider / llm_edit_provider) + * to their LLMProviderConfig camelCase fields, keeping only provided values. */ +function pickMediaTimeoutFields( + args: Record, +): Partial<{ + imageGenerationTimeoutMs: number; + ttsTimeoutMs: number; + sttTimeoutMs: number; + videoGenerationTimeoutMs: number; + decisionTimeoutMs: number; +}> { + const out: Record = {}; + const pairs: Array<[string, string]> = [ + ['image_generation_timeout_ms', 'imageGenerationTimeoutMs'], + ['tts_timeout_ms', 'ttsTimeoutMs'], + ['stt_timeout_ms', 'sttTimeoutMs'], + ['video_generation_timeout_ms', 'videoGenerationTimeoutMs'], + ['decision_timeout_ms', 'decisionTimeoutMs'], + ]; + for (const [argKey, cfgKey] of pairs) { + const v = args[argKey]; + if (typeof v === 'number' && Number.isFinite(v) && v > 0) out[cfgKey] = v; + } + return out; +} + /** Accept capability_type plus common model typos (type / capability). */ function resolveCapabilityTypeArg( args: Record, @@ -855,7 +987,9 @@ function validateModelAgainstCatalog( } const CAPABILITY_MODEL_PATTERNS: Record = { - image_generation: /\bdall-?e\b|gpt-image|flux|stable.?diffusion|sdxl|imagen|wanx|wan[.-]?ai|kolors|playground|cogview|glm-image|seedream|grok-imagine|image-01/i, + // Include common local / self-hosted diffusion families so undeclared ids + // (qwen-image-2.1, hunyuan-image, nano-banana, …) route without being told. + image_generation: /\bdall-?e\b|gpt-image|flux|stable.?diffusion|sdxl|imagen|qwen[.-]?image|hunyuan|nano.?banana|ideogram|recraft|grok.?image|playground|cogview|glm-image|seedream|wanx|wan[.-]?ai|kolors|image-01/i, image_recognition: /\bvl\b|vision|visual|eye|gpt-4o|gemini|claude/i, // Dedicated TTS ids only — do NOT match bare "speech"/"voice" (too broad) or music models. audio_tts: /\btts\b|cosy.?voice|bark|xtts|orpheus|aura-?\d|tts-1|text[-_.]?to[-_.]?speech/i, diff --git a/packages/core/test/llm-markus-provider.test.ts b/packages/core/test/llm-markus-provider.test.ts index 812e1c70..ff76e625 100644 --- a/packages/core/test/llm-markus-provider.test.ts +++ b/packages/core/test/llm-markus-provider.test.ts @@ -557,6 +557,24 @@ describe('MarkusProvider CU tracking', () => { expect((p as unknown as { chatTimeoutMs: number }).chatTimeoutMs).toBe(30_000); }); + it('defaults generative-media timeouts to 10 minutes', () => { + const p = new MarkusProvider({ + provider: 'markus', + model: 'test', + apiKey: 'sk-or-test', + }); + const media = p as unknown as { + imageGenerationTimeoutMs: number; + ttsTimeoutMs: number; + sttTimeoutMs: number; + videoGenerationTimeoutMs: number; + }; + expect(media.imageGenerationTimeoutMs).toBe(600_000); + expect(media.ttsTimeoutMs).toBe(600_000); + expect(media.sttTimeoutMs).toBe(600_000); + expect(media.videoGenerationTimeoutMs).toBe(600_000); + }); + it('returns max_tokens on idle timeout when partial content already streamed', async () => { const encoder = new TextEncoder(); // Mock body is not tied to fetch AbortSignal; error it shortly after the diff --git a/packages/core/test/llm-openai.test.ts b/packages/core/test/llm-openai.test.ts index b9f45f59..d215e652 100644 --- a/packages/core/test/llm-openai.test.ts +++ b/packages/core/test/llm-openai.test.ts @@ -36,6 +36,25 @@ describe('OpenAIProvider', () => { expect(provider.model).toBe('gpt-4o'); }); + it('defaults generative-media timeouts to 10 minutes', () => { + const p = new OpenAIProvider({ + provider: 'openai', + model: 'gpt-4o', + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com', + }); + const media = p as unknown as { + imageGenerationTimeoutMs: number; + ttsTimeoutMs: number; + sttTimeoutMs: number; + videoGenerationTimeoutMs: number; + }; + expect(media.imageGenerationTimeoutMs).toBe(600_000); + expect(media.ttsTimeoutMs).toBe(600_000); + expect(media.sttTimeoutMs).toBe(600_000); + expect(media.videoGenerationTimeoutMs).toBe(600_000); + }); + it('configure updates settings', () => { provider.configure({ provider: 'openai', @@ -48,6 +67,41 @@ describe('OpenAIProvider', () => { expect(provider.model).toBe('gpt-5.4'); }); + it('honors per-modality media timeouts from config', async () => { + const p = new OpenAIProvider({ + provider: 'openai', + model: 'gpt-4o', + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com', + imageGenerationTimeoutMs: 240_000, + ttsTimeoutMs: 300_000, + sttTimeoutMs: 200_000, + }); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: [{ url: 'https://cdn.example/img.png' }] }), + })); + await p.generateImage('a cat'); + expect(timeoutSpy).toHaveBeenCalledWith(240_000); + + timeoutSpy.mockClear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3]).buffer), + })); + await p.generateSpeech('hello'); + expect(timeoutSpy).toHaveBeenCalledWith(300_000); + + timeoutSpy.mockClear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve('transcribed'), + })); + await p.transcribeSpeech(new Uint8Array([1, 2, 3])); + expect(timeoutSpy).toHaveBeenCalledWith(200_000); + }); + it('chat returns success response', async () => { let capturedUrl = ''; const mockFetch = vi.fn().mockImplementation((url: string) => { diff --git a/packages/core/test/llm-router.test.ts b/packages/core/test/llm-router.test.ts index 6feba7b3..bc53887c 100644 --- a/packages/core/test/llm-router.test.ts +++ b/packages/core/test/llm-router.test.ts @@ -880,6 +880,21 @@ describe('LLMRouter resolveModalityProvider fallbacks', () => { expect(resolved?.model).toBe('image-01'); }); + it('does NOT carry a text routing-default model id into a non-text fallback', () => { + // Regression for P1-3: when the global routing default is a chat model + // (gpt-4o), a media capability that has no assignment must resolve to the + // provider WITHOUT that text id — POSTing "gpt-4o" to an images endpoint + // yields a confusing upstream 404 ("model not found for this capability"). + const router = new LLMRouter('openai'); + const openai = mockProvider('openai', 'gpt-4o') as MultiModalProviderInterface; + router.registerProvider('openai', openai); + router.setRoutingDefaultModel({ provider: 'openai', model: 'gpt-4o' }); + + const resolved = router.resolveModalityProvider('image_generation'); + expect(resolved?.provider).toBe(openai); + expect(resolved?.model).toBeUndefined(); + }); + it('returns undefined when no providers are available', () => { const router = new LLMRouter('openai'); router.registerProvider('openai', mockProvider('openai', 'gpt-4o')); diff --git a/packages/core/test/multimodal-tools.test.ts b/packages/core/test/multimodal-tools.test.ts index f87eaf81..cf2a0d89 100644 --- a/packages/core/test/multimodal-tools.test.ts +++ b/packages/core/test/multimodal-tools.test.ts @@ -256,6 +256,56 @@ describe('createMultiModalTools', () => { expect(result.error).toContain('openai'); }); + it('splits a qualified provider/model id so a chat model never hits the image endpoint', async () => { + // Regression for P1-3: passing model="deepseek/deepseek-v4-flash-0731" + // (a chat id) with NO provider used to stamp that id onto every image + // candidate and POST it to the images endpoint (404 "model not found"). + // The qualified prefix must instead route through the explicit-provider + // path, where the modality-support check names the real problem. + const imageProvider = createMockProvider({ generateImage: vi.fn().mockResolvedValue([]), name: 'markus' }); + const chatProvider = createMockProvider({ name: 'deepseek', model: 'deepseek-v4-flash-0731' }); + const tools = createMultiModalTools( + createContext( + { image_generation: [{ provider: imageProvider, name: 'markus', model: 'openai/gpt-image-1' }] }, + { + resolveProvider: (name) => + name === 'deepseek' ? { provider: chatProvider, name: 'deepseek' } : undefined, + listProviderNames: () => ['markus', 'deepseek'], + }, + ), + ); + const result = JSON.parse(await img(tools).execute({ + prompt: 'a cat', + model: 'deepseek/deepseek-v4-flash-0731', + })); + expect(result.status).toBe('error'); + expect(result.error).toContain('does not support this modality'); + expect(result.error).toContain('deepseek'); + expect(imageProvider.generateImage).not.toHaveBeenCalled(); + }); + + it('splits a qualified model id into its provider when that provider is capable', async () => { + const generateImage = vi.fn().mockResolvedValue([]); + const openai = createMockProvider({ generateImage, name: 'openai', model: 'gpt-image-1' }); + const tools = createMultiModalTools( + createContext( + { image_generation: [{ provider: openai, name: 'openai', model: 'gpt-image-1' }] }, + { + resolveProvider: (name) => (name === 'openai' ? { provider: openai, name: 'openai' } : undefined), + listProviderNames: () => ['openai'], + }, + ), + ); + const result = JSON.parse(await img(tools).execute({ + prompt: 'a cat', + model: 'openai/gpt-image-1', + })); + expect(result.status).toBe('success'); + // The provider prefix is consumed for routing; the bare model id is what + // reaches the upstream (same as provider="openai" model="gpt-image-1"). + expect(generateImage).toHaveBeenCalledWith('a cat', expect.objectContaining({ model: 'gpt-image-1' })); + }); + it('rejects disabled provider with an explicit disabled error', async () => { const generateImage = vi.fn(); const tools = createMultiModalTools( diff --git a/packages/core/test/settings-tools-extended.test.ts b/packages/core/test/settings-tools-extended.test.ts index 3115c66b..4fc008fd 100644 --- a/packages/core/test/settings-tools-extended.test.ts +++ b/packages/core/test/settings-tools-extended.test.ts @@ -128,4 +128,89 @@ describe('settings tools extended coverage', () => { expect(result.status).toBe('success'); expect(router.addCustomModel).toHaveBeenCalled(); }); + + it('llm_add_model forwards capabilities declaration', async () => { + const router = createMockRouter(); + const persistConfig = vi.fn(); + const tool = findTool(router, 'llm_add_model', persistConfig); + const result = JSON.parse(await tool.execute({ + provider: 'localfn', + id: 'qwen-image-2.1', + name: 'Qwen Image 2.1 (local)', + context_window: 0, + max_output_tokens: 0, + cost_input: 0, + cost_output: 0, + capabilities: ['imageGeneration'], + })); + expect(result.status).toBe('success'); + expect(router.addCustomModel).toHaveBeenCalledWith( + 'localfn', + expect.objectContaining({ + id: 'qwen-image-2.1', + capabilities: ['imageGeneration'], + }), + ); + }); + + it('llm_add_model persists capabilities', async () => { + const router = createMockRouter(); + const persistConfig = vi.fn(); + const tool = findTool(router, 'llm_add_model', persistConfig); + await tool.execute({ + provider: 'openai', + id: 'custom-tts', + name: 'Custom TTS', + context_window: 0, + max_output_tokens: 0, + cost_input: 0, + cost_output: 0, + capabilities: ['tts'], + }); + const persisted = persistConfig.mock.calls[0][0]; + const model = persisted.llm.customModels.openai[0]; + expect(model.capabilities).toEqual(['tts']); + }); + + it('llm_add_provider forwards media timeout config and persists it', async () => { + const router = createMockRouter(); + const persistConfig = vi.fn(); + const tool = findTool(router, 'llm_add_provider', persistConfig); + const result = JSON.parse(await tool.execute({ + name: 'local-image', + model: 'qwen-image-2.1', + api_key: 'local', + base_url: 'http://127.0.0.1:8391/v1', + image_generation_timeout_ms: 300000, + tts_timeout_ms: 240000, + })); + expect(result.status).toBe('success'); + expect(router.registerProviderFromConfig).toHaveBeenCalledWith( + 'local-image', + expect.objectContaining({ + imageGenerationTimeoutMs: 300000, + ttsTimeoutMs: 240000, + }), + ); + const persisted = persistConfig.mock.calls[0][0]; + expect(persisted.llm.providers['local-image'].imageGenerationTimeoutMs).toBe(300000); + }); + + it('llm_edit_provider forwards media timeout config to configure and persist', async () => { + const router = createMockRouter(); + const persistConfig = vi.fn(); + const tool = findTool(router, 'llm_edit_provider', persistConfig); + const result = JSON.parse(await tool.execute({ + provider: 'openai', + image_generation_timeout_ms: 240000, + decision_timeout_ms: 90000, + })); + expect(result.status).toBe('success'); + const provider = router.getProvider('openai') as { configure: ReturnType }; + expect(provider.configure).toHaveBeenCalledWith( + expect.objectContaining({ imageGenerationTimeoutMs: 240000, decisionTimeoutMs: 90000 }), + ); + const persisted = persistConfig.mock.calls[0][0]; + expect(persisted.llm.providers.openai.imageGenerationTimeoutMs).toBe(240000); + }); }); diff --git a/packages/core/test/settings-tools.test.ts b/packages/core/test/settings-tools.test.ts index 5779ea77..5949d749 100644 --- a/packages/core/test/settings-tools.test.ts +++ b/packages/core/test/settings-tools.test.ts @@ -196,6 +196,93 @@ describe('createSettingsTools', () => { })); expect(result.status).toBe('success'); }); + + it('trusts explicit capabilities declaration over the name heuristic', async () => { + // A local model whose id contains a text-model family name ("qwen") + // previously failed the regex gate. Once it carries an explicit + // imageGeneration declaration, routing must trust that declaration. + const router = createMockRouter(); + (router.getEnhancedSettings as ReturnType).mockReturnValue({ + defaultProvider: 'localfn', + providers: { + localfn: { + displayName: 'Local vLLM', + model: 'qwen-image-2.1', + configured: true, + enabled: true, + models: [ + { + id: 'qwen-image-2.1', + name: 'Qwen Image 2.1', + contextWindow: 0, + maxOutputTokens: 0, + cost: { input: 0, output: 0 }, + inputTypes: ['text'], + capabilities: ['imageGeneration'], + }, + ], + }, + }, + }); + const tool = findTool(router, 'llm_set_capability_routing'); + const result = JSON.parse(await tool.execute({ + capability_type: 'image_generation', + provider: 'localfn', + model: 'qwen-image-2.1', + })); + expect(result.status).toBe('success'); + expect(result.model).toBe('qwen-image-2.1'); + }); + + it('force bypasses the name heuristic for undeclared models', async () => { + const router = createMockRouter(); + const tool = findTool(router, 'llm_set_capability_routing'); + const result = JSON.parse(await tool.execute({ + capability_type: 'image_generation', + provider: 'openai', + model: 'gpt-4o', + force: true, + })); + expect(result.status).toBe('success'); + expect(result.model).toBe('gpt-4o'); + }); + + it('force does not bypass an explicit catalog capability mismatch', async () => { + // A catalog entry that DECLARES capabilities but lacks the required tag + // is a hard contradiction — force is only an escape hatch for the name + // heuristic, not for the declared truth. + const router = createMockRouter(); + (router.getEnhancedSettings as ReturnType).mockReturnValue({ + defaultProvider: 'markus', + providers: { + markus: { + displayName: 'Markus', + model: 'openai/gpt-audio', + configured: true, + enabled: true, + models: [ + { + id: 'openai/gpt-audio', + name: 'GPT Audio', + contextWindow: 0, + maxOutputTokens: 0, + cost: { input: 0, output: 0 }, + capabilities: ['audioOutput', 'audioInput'], + }, + ], + }, + }, + }); + const tool = findTool(router, 'llm_set_capability_routing'); + const result = JSON.parse(await tool.execute({ + capability_type: 'audio_tts', + provider: 'markus', + model: 'openai/gpt-audio', + force: true, + })); + expect(result.status).toBe('error'); + expect(result.error).toContain('not tagged'); + }); }); describe('llm_set_capability_routing validation', () => { diff --git a/packages/shared/src/types/llm.ts b/packages/shared/src/types/llm.ts index e7bebbfb..d2eb803c 100644 --- a/packages/shared/src/types/llm.ts +++ b/packages/shared/src/types/llm.ts @@ -43,6 +43,21 @@ export interface LLMProviderConfig { timeoutMs?: number; /** Max idle gap between stream chunks in milliseconds (default: 180s). Independent of timeoutMs. */ streamTimeoutMs?: number; + /** + * Image generation request timeout in milliseconds (default: 600s / 10min). + * Generative media (diffusion) is naturally slower than chat; the default is + * deliberately generous so local/self-hosted model servers (diffusers, vLLM) + * that cold-load weights on the first request do not abort mid-generation. + */ + imageGenerationTimeoutMs?: number; + /** Text-to-speech request timeout in milliseconds (default: 600s / 10min). */ + ttsTimeoutMs?: number; + /** Speech-to-text request timeout in milliseconds (default: 600s / 10min). */ + sttTimeoutMs?: number; + /** Video generation request timeout in milliseconds (default: 600s / 10min). */ + videoGenerationTimeoutMs?: number; + /** Typed-probability decision request timeout in milliseconds (default: 60s). */ + decisionTimeoutMs?: number; /** Max retries for transient HTTP/network failures (default: 3). */ maxRetries?: number; /** Base exponential-backoff delay in ms for retries (default: 500). */ diff --git a/packages/web-ui/src/components/ChatHistorySearch.tsx b/packages/web-ui/src/components/ChatHistorySearch.tsx index 80f0297f..05bf36e9 100644 --- a/packages/web-ui/src/components/ChatHistorySearch.tsx +++ b/packages/web-ui/src/components/ChatHistorySearch.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type { ChatSearchMatch } from '../lib/chatSearch.ts'; +import type { SearchResult } from '../api.ts'; /** * Find-in-conversation bar. @@ -27,6 +28,10 @@ export interface ChatHistorySearchProps { onClose: () => void; /** Optional per-message label (sender / time) shown next to each hit. */ labelFor?: (match: ChatSearchMatch) => string; + /** Full-history results from the server FTS5 index (the merged second search). */ + serverResults?: readonly SearchResult[]; + serverLoading?: boolean; + onServerResultClick?: (result: SearchResult) => void; } const FIELD_KEY: Record = { @@ -58,6 +63,7 @@ function Snippet({ match }: { match: ChatSearchMatch }) { export function ChatHistorySearch({ query, onQueryChange, matches, cursor, scanned, hasMore, loadingMore, truncated, onLoadEarlier, onJump, onClose, labelFor, + serverResults, serverLoading, onServerResultClick, }: ChatHistorySearchProps) { const { t } = useTranslation(['team', 'common']); const inputRef = useRef(null); @@ -174,6 +180,40 @@ export function ChatHistorySearch({ ))} )} + + {/* Full-history server results (merged with find-in-conversation): when the + local transcript scan is exhausted or the user wants older messages, + the same panel also lists matches from the server FTS5 index. */} + {hasQuery && (serverLoading || (serverResults && serverResults.length > 0)) && ( +
+
{t('page.findServerResultsTitle')}
+ {serverLoading && ( +
{t('page.searching')}
+ )} + {serverResults && serverResults.length > 0 && ( +
+ {serverResults.map(r => ( + + ))} +
+ )} +
+ )} ); } diff --git a/packages/web-ui/src/locales/en/team.json b/packages/web-ui/src/locales/en/team.json index ab792ff3..3e6d965d 100644 --- a/packages/web-ui/src/locales/en/team.json +++ b/packages/web-ui/src/locales/en/team.json @@ -445,6 +445,7 @@ "findScopeHasMore": "Earlier messages are not loaded yet", "findLoadEarlier": "Load earlier messages", "findLoadingEarlier": "Loading…", + "findServerResultsTitle": "All history", "findFieldText": "Message", "findFieldThinking": "Thinking", "findFieldTool": "Tool", diff --git a/packages/web-ui/src/locales/es/team.json b/packages/web-ui/src/locales/es/team.json index c3c043a8..a31dd6b6 100644 --- a/packages/web-ui/src/locales/es/team.json +++ b/packages/web-ui/src/locales/es/team.json @@ -437,6 +437,7 @@ "findScopeHasMore": "Los mensajes anteriores aún no están cargados", "findLoadEarlier": "Cargar mensajes anteriores", "findLoadingEarlier": "Cargando…", + "findServerResultsTitle": "Todo el historial", "findFieldText": "Mensaje", "findFieldThinking": "Pensando", "findFieldTool": "Herramienta", diff --git a/packages/web-ui/src/locales/zh-CN/team.json b/packages/web-ui/src/locales/zh-CN/team.json index ce5721b4..2c104635 100644 --- a/packages/web-ui/src/locales/zh-CN/team.json +++ b/packages/web-ui/src/locales/zh-CN/team.json @@ -445,6 +445,7 @@ "findScopeHasMore": "更早的消息还未加载", "findLoadEarlier": "加载更早的消息", "findLoadingEarlier": "正在加载…", + "findServerResultsTitle": "全部历史", "findFieldText": "正文", "findFieldThinking": "思考", "findFieldTool": "工具", diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index 190a3f4d..f3e8d8ef 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -54,7 +54,7 @@ import { renderMentionText } from '../components/CommentInput.tsx'; import { ChatTeamSidebar } from '../components/ChatTeamSidebar.tsx'; import { TeamDetailPanel } from '../components/TeamDetailPanel.tsx'; import { RightPanel } from '../components/RightPanel.tsx'; -import { ChatSearchPanel, GroupMemberPanel, type PanelCandidate } from './teamPanels.tsx'; +import { GroupMemberPanel, type PanelCandidate } from './teamPanels.tsx'; import { ChatHistorySearch } from '../components/ChatHistorySearch.tsx'; import { searchChatHistory } from '../lib/chatSearch.ts'; import { useLayout } from '../contexts/LayoutContext.tsx'; @@ -866,19 +866,16 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: const pendingSelectTeamRef = useRef(null); const [showMemberPanel, setShowMemberPanel] = useState(false); - // Message search - const [searchOpen, setSearchOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); - const searchDebounceRef = useRef>(undefined); - - // Find-in-conversation (this session's transcript only). Separate from the - // header's global search: that one queries the server across all conversations, - // this one is instant, local, and jumps the virtualized list to the hit. + // Find + full-history search share ONE panel: instant local matches over the + // loaded transcript PLUS server FTS5 results across the whole chat history. + // (The two search UIs were merged to avoid two overlapping search entry + // points with the same keyboard shortcut.) const [findOpen, setFindOpen] = useState(false); const [findQuery, setFindQuery] = useState(''); const [findCursor, setFindCursor] = useState(-1); + const [findServerResults, setFindServerResults] = useState([]); + const [findServerLoading, setFindServerLoading] = useState(false); + const findServerDebounceRef = useRef>(undefined); // Teams const [teams, setTeams] = useState(previewData?.teams ?? []); @@ -1948,20 +1945,25 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: requestAnimationFrame(flash); }, [findOutcome.matches, chatVirtualizer, pinChatScrollAway]); - // Ctrl/Cmd+F opens find-in-conversation while the chat tab is showing — the - // convention in every chat client, and the only discoverable entry point on - // mobile. Native browser find is not useful inside the Electron shell here. + // Cmd+F (Mac) / Ctrl+F (Win/Linux) opens find-in-conversation while the chat + // tab is showing — the convention in every chat client, and the only + // discoverable entry point on mobile. Native browser find is not useful + // inside the Electron shell here. + // NOTE: on macOS, Ctrl+F is the native "cursor forward" shortcut, so only + // ⌘F is hijacked there; Ctrl+F falls through to the platform default. useEffect(() => { if (mainTab !== 'chat' || previewMode) return; const onKey = (e: KeyboardEvent) => { if (e.key.toLowerCase() !== 'f') return; - if (!(e.metaKey || e.ctrlKey) || e.altKey) return; + if (e.altKey || e.shiftKey) return; + const isFindShortcut = isMac ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey); + if (!isFindShortcut) return; e.preventDefault(); setFindOpen(true); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); - }, [mainTab, previewMode]); + }, [mainTab, previewMode, isMac]); // ── Preserve scroll position across page-level navigation ── // PageSlot now uses visibility:hidden + position:absolute instead of @@ -2959,28 +2961,32 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: } }; - const executeSearch = useCallback(async (q: string) => { - if (q.length < 2) { setSearchResults([]); return; } - setSearchLoading(true); + // ── Full-history search (merged into the find panel) ────────────────────── + // The find-in-conversation bar now ALSO queries the server FTS5 index so the + // same panel covers the loaded transcript (instant, local) AND the whole + // history (server). This replaces the old separate ChatSearchPanel. + const runFindServerSearch = useCallback(async (q: string) => { + if (q.trim().length < 2) { setFindServerResults([]); return; } + setFindServerLoading(true); try { const scope = chatMode === 'channel' ? 'channel' : chatMode === 'direct' ? 'direct' : 'all'; const channel = chatMode === 'channel' ? activeChannel : undefined; const { results } = await api.messages.search(q, { scope, channel, limit: 30 }); - setSearchResults(results); - } catch { setSearchResults([]); } - setSearchLoading(false); + setFindServerResults(results); + } catch { setFindServerResults([]); } + setFindServerLoading(false); }, [chatMode, activeChannel]); - const handleSearchInput = useCallback((q: string) => { - setSearchQuery(q); - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = setTimeout(() => executeSearch(q), 300); - }, [executeSearch]); + const handleFindInput = useCallback((q: string) => { + setFindQuery(q); + if (findServerDebounceRef.current) clearTimeout(findServerDebounceRef.current); + findServerDebounceRef.current = setTimeout(() => void runFindServerSearch(q), 300); + }, [runFindServerSearch]); - const handleSearchResultClick = useCallback((result: import('../api.ts').SearchResult) => { - setSearchOpen(false); - setSearchQuery(''); - setSearchResults([]); + const handleFindServerResultClick = useCallback((result: import('../api.ts').SearchResult) => { + setFindOpen(false); + setFindQuery(''); + setFindServerResults([]); if (result.source === 'channel' && result.channel) { setChatMode('channel'); setActiveChannel(result.channel); @@ -3867,8 +3873,8 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: >{chatMode === 'channel' ? t('page.teamTab') : t('page.profileTab')}
@@ -4071,15 +4077,6 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: - {chatMode === 'channel' && activeGroupChat?.type === 'custom' && (