feat(ai): redesign AI settings page with per-endpoint test buttons

Rebuild the workspace AI settings page into card-based "Endpoints"
(Chat / Embeddings / Voice) matching the new design, and split the
single connection test into independent per-endpoint Test buttons.

- server: testConnection(workspaceId, capability) probes only the
  requested capability ('chat' | 'embeddings'); add TestAiConnectionDto
  and wire it through the /workspace/ai-settings/test controller
- client: testAiConnection(capability) + capability-typed mutation; two
  independent test mutation instances so Chat/Embeddings results are isolated
- client: full rewrite of ai-provider-settings into Endpoints section —
  drop the provider dropdown (driver is always openai, base URL + key
  always shown), move the "AI chat" and surface the "Semantic search"
  feature toggles into card headers, system message behind an Edit modal,
  pgvector/reindex footer, and a disabled Voice/STT stub
- client: restyle external MCP tools and the MCP server section; collapse
  the AI sections in workspace-settings; remove the standalone
  ai-chat-settings component
- toggles now surface the server error message (e.g. missing pgvector)
- i18n: add new English strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vvzvlad
2026-06-18 04:20:33 +03:00
parent c292894c59
commit 87d6bdfbd9
11 changed files with 692 additions and 386 deletions

View File

@@ -161,59 +161,53 @@ export class AiService {
}
/**
* Cheap connectivity check for the "Test connection" button. Probes the
* configured chat model (a one-word generation) AND the configured embeddings
* model (embedding a tiny string) independently:
* - a probe is skipped when that capability is not configured (its
* NotConfigured exception), so a chat-only or embeddings-only workspace
* still tests fine;
* - any real failure returns ok:false with the provider's own cause
* (statusCode + truncated response body via describeProviderError),
* prefixed Chat: / Embeddings: so the failing side is obvious;
* - if neither capability is configured, reports "not configured".
* Cheap connectivity check for a single "Test endpoint" button. Probes ONLY
* the requested capability so each card in the UI surfaces its own result:
* - `chat`: a one-word generation against the configured chat model;
* - `embeddings`: embedding a tiny string against the embedding model.
*
* A capability that is not configured returns a plain "… is not configured"
* message; any real failure returns ok:false with the provider's own cause
* (statusCode + truncated response body via describeProviderError). The
* decrypted key is never logged or returned — AI SDK error fields do not carry
* it, and the resolved config is never dumped.
*
* Probing embeddings here catches a misconfigured embeddings endpoint (e.g.
* one returning non-JSON, which the background RAG indexer would otherwise hit
* as an opaque "Invalid JSON response") at config time instead of silently
* during indexing. The decrypted key is never logged or returned — AI SDK
* error fields do not carry it, and the resolved config is never dumped.
* during indexing.
*/
async testConnection(
workspaceId: string,
capability: 'chat' | 'embeddings' = 'chat',
): Promise<{ ok: true } | { ok: false; error: string }> {
let probed = false;
if (capability === 'embeddings') {
try {
await this.embedTexts(workspaceId, ['ping']);
return { ok: true };
} catch (err) {
if (err instanceof AiEmbeddingNotConfiguredException) {
return { ok: false, error: 'Embeddings are not configured' };
}
this.logger.error('AI embedding test connection failed', err as Error);
return { ok: false, error: describeProviderError(err) };
}
}
// Chat probe — only when a chat model is configured.
// Default: chat probe.
try {
const model = await this.getChatModel(workspaceId);
// maxOutputTokens keeps the probe cheap and avoids providers (e.g.
// OpenRouter) reserving/charging for the model's full max-token budget,
// which would 402 on a key with limited credit.
await generateText({ model, prompt: 'ping', maxOutputTokens: 16 });
probed = true;
return { ok: true };
} catch (err) {
if (!(err instanceof AiNotConfiguredException)) {
this.logger.error('AI chat test connection failed', err as Error);
return { ok: false, error: `Chat: ${describeProviderError(err)}` };
if (err instanceof AiNotConfiguredException) {
return { ok: false, error: 'Chat is not configured' };
}
// Chat not configured: skip — embeddings may still be configured.
this.logger.error('AI chat test connection failed', err as Error);
return { ok: false, error: describeProviderError(err) };
}
// Embedding probe — only when an embedding model is configured.
try {
await this.embedTexts(workspaceId, ['ping']);
probed = true;
} catch (err) {
if (!(err instanceof AiEmbeddingNotConfiguredException)) {
this.logger.error('AI embedding test connection failed', err as Error);
return { ok: false, error: `Embeddings: ${describeProviderError(err)}` };
}
// Embeddings not configured: skip.
}
if (!probed) {
return { ok: false, error: 'AI provider not configured' };
}
return { ok: true };
}
}