fix(ai-chat): OpenAI Chat Completions for multi-turn + provider settings, stream UX & errors" -m "Live-stand fixes (OpenRouter / OpenAI-compatible):
- openai provider: use .chat() (Chat Completions) instead of the default callable (Responses API), which gateways reject on multi-turn -> 400. - updateAiProviderSettings: assemble settings.ai.provider via jsonb_build_object with ::text-cast bound params + jsonb_typeof self-heal (postgres.js was double-encoding it into an array; the ::text cast avoids 'could not determine data type of parameter'). - chat agent: drop the hard maxOutputTokens cap (truncated complex tool calls); keep a tiny cap only on the test-connection ping. - testConnection + chat stream: surface the real provider error (statusCode+message) to logs and the UI instead of generic masks; never log the API key. - chat UI: typing indicator, incremental streaming render, tool 'running' status, Stop. Also bundled (prior uncommitted ai-chat work): - history 'AI agent' provenance badge; vector RAG (pgvector image + page_embeddings + AI_QUEUE indexer + space-scoped semanticSearch); external MCP servers backend (@ai-sdk/mcp client, SSRF IP-pinning, encrypted headers, admin CRUD/Test); yjs duplicate-instance fix via pnpm patch (single CJS instance server-side).
This commit is contained in:
@@ -214,11 +214,16 @@ export class WorkspaceRepo {
|
||||
/**
|
||||
* Deep-merge a partial provider config into the fixed path
|
||||
* `settings.ai.provider`. Unlike `updateAiSettings` (single scalar key under
|
||||
* `settings.ai`), this stores a nested object. The path is constant — only the
|
||||
* provider value is parameterized (bound, not `sql.raw`) — so it cannot store
|
||||
* a secret and is safe from injection. Sibling `settings.ai.*` keys (search /
|
||||
* generative / chat / mcp / systemPrompt) and provider fields absent from the
|
||||
* partial are preserved via jsonb `||` merge.
|
||||
* `settings.ai`), this stores a nested object. The provider object is assembled
|
||||
* IN SQL via `jsonb_build_object`: keys come from a fixed allowlist (inlined
|
||||
* via `sql.lit`, so no injection) and values are bound params, so the result is
|
||||
* a real jsonb object and never a double-encoded string (postgres.js would
|
||||
* otherwise re-serialize a `JSON.stringify`'d string, yielding a jsonb string
|
||||
* that `||` turns into an array). A `jsonb_typeof = 'object'` CASE self-heals
|
||||
* workspaces whose `settings.ai.provider` was previously corrupted into an
|
||||
* array/string. Sibling `settings.ai.*` keys (search / generative / chat / mcp
|
||||
* / systemPrompt) and provider fields absent from the partial are preserved via
|
||||
* jsonb `||` merge.
|
||||
*/
|
||||
async updateAiProviderSettings(
|
||||
workspaceId: string,
|
||||
@@ -226,14 +231,33 @@ export class WorkspaceRepo {
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Workspace> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const providerJson = JSON.stringify(provider);
|
||||
// Assemble the provider object IN SQL. Keys are fixed provider field names
|
||||
// (sql.lit -> inlined literals, no injection); values are bound params cast
|
||||
// to ::text — postgres.js sends bound params untyped, and jsonb_build_object's
|
||||
// value args are polymorphic ("any"), so without the explicit ::text cast
|
||||
// Postgres throws "could not determine data type of parameter $1". The result
|
||||
// is a real jsonb object, never a double-encoded string. The CASE self-heals
|
||||
// workspaces whose settings.ai.provider was previously corrupted into an
|
||||
// array/string.
|
||||
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'systemPrompt'];
|
||||
const entries = Object.entries(provider).filter(
|
||||
([k, v]) => v !== undefined && ALLOWED.includes(k),
|
||||
);
|
||||
const patch = entries.length
|
||||
? sql`jsonb_build_object(${sql.join(
|
||||
entries.flatMap(([k, v]) => [sql.lit(k), sql`${v}::text`]),
|
||||
)})`
|
||||
: sql`'{}'::jsonb`;
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
||||
|| jsonb_build_object('provider', COALESCE(settings->'ai'->'provider', '{}'::jsonb)
|
||||
|| ${providerJson}::jsonb))`,
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb) || jsonb_build_object(
|
||||
'ai', COALESCE(settings->'ai', '{}'::jsonb) || jsonb_build_object(
|
||||
'provider',
|
||||
(CASE WHEN jsonb_typeof(settings->'ai'->'provider') = 'object'
|
||||
THEN settings->'ai'->'provider' ELSE '{}'::jsonb END)
|
||||
|| ${patch}
|
||||
))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
|
||||
Reference in New Issue
Block a user