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:
vvzvlad
2026-06-17 04:28:29 +03:00
parent 44b340dc1a
commit a4b7919753
44 changed files with 2633 additions and 122 deletions

View File

@@ -0,0 +1,143 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import { AiMcpServer } from '@docmost/db/types/entity.types';
/**
* Repository for per-workspace external MCP servers the agent may use (§5.4).
*
* SECURITY (§8.10): rows hold the encrypted auth-header blob (`headersEnc`).
* That column must NEVER be returned to a non-admin path nor logged; the admin
* controller projects an explicit allowlist of columns and the connect path
* decrypts only server-side. All lookups are workspace-scoped.
*/
@Injectable()
export class AiMcpServerRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
id: string,
workspaceId: string,
): Promise<AiMcpServer | undefined> {
return this.db
.selectFrom('aiMcpServers')
.selectAll('aiMcpServers')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async listByWorkspace(workspaceId: string): Promise<AiMcpServer[]> {
return this.db
.selectFrom('aiMcpServers')
.selectAll('aiMcpServers')
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc')
.execute();
}
/** Enabled servers only — used by the agent loop to build the toolset. */
async listEnabled(workspaceId: string): Promise<AiMcpServer[]> {
return this.db
.selectFrom('aiMcpServers')
.selectAll('aiMcpServers')
.where('workspaceId', '=', workspaceId)
.where('enabled', '=', true)
.orderBy('createdAt', 'asc')
.execute();
}
async insert(
values: {
workspaceId: string;
name: string;
transport: string;
url: string;
headersEnc?: string | null;
toolAllowlist?: string[] | null;
enabled?: boolean;
},
trx?: KyselyTransaction,
): Promise<AiMcpServer> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiMcpServers')
.values({
workspaceId: values.workspaceId,
name: values.name,
transport: values.transport,
url: values.url,
headersEnc: values.headersEnc ?? null,
// jsonb column: the postgres driver would otherwise encode a JS array as
// a Postgres array literal. Bind the JSON text and cast it to jsonb.
toolAllowlist: jsonbArray(values.toolAllowlist),
enabled: values.enabled ?? true,
})
.returningAll()
.executeTakeFirst();
}
async update(
id: string,
workspaceId: string,
patch: {
name?: string;
transport?: string;
url?: string;
// undefined => leave unchanged; null => clear; string => set.
headersEnc?: string | null;
// undefined => leave unchanged; null => clear; string[] => set.
toolAllowlist?: string[] | null;
enabled?: boolean;
},
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
const set: Record<string, unknown> = { updatedAt: new Date() };
if (patch.name !== undefined) set.name = patch.name;
if (patch.transport !== undefined) set.transport = patch.transport;
if (patch.url !== undefined) set.url = patch.url;
if (patch.headersEnc !== undefined) set.headersEnc = patch.headersEnc;
if (patch.toolAllowlist !== undefined) {
set.toolAllowlist = jsonbArray(patch.toolAllowlist);
}
if (patch.enabled !== undefined) set.enabled = patch.enabled;
await db
.updateTable('aiMcpServers')
.set(set)
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
async delete(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('aiMcpServers')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
}
/**
* Encode a string[] as a jsonb bind for the `tool_allowlist` column. Passing a
* plain JS array to the postgres driver would serialize it as a Postgres array
* literal (incompatible with jsonb), so we bind the JSON text and cast it.
* Returns null for null/empty arrays (an empty allowlist means "no restriction"
* is not intended — callers pass null to clear; an empty array is normalized to
* null here so it never round-trips as `[]`).
*/
function jsonbArray(value: string[] | null | undefined) {
if (value === null || value === undefined || value.length === 0) {
return null;
}
// Typed as string[] so it is assignable to the toolAllowlist column.
return sql<string[]>`${JSON.stringify(value)}::jsonb`;
}

View File

@@ -0,0 +1,142 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import * as pgvector from 'pgvector';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
/**
* Repository for `page_embeddings` — the pgvector store backing the AI agent's
* semantic search (§5.5 / §6.7 stage D).
*
* The `embedding` column is `vector(1536)`, which is NOT a native Kysely column
* type, so every read/write of a vector is serialized with the `pgvector` npm
* helper (`pgvector.toSql(number[])` → a `'[1,2,3]'` text literal) and cast back
* to `vector` via a raw `::vector` SQL cast. Reindex is a HARD delete + insert
* (see `deleteByPage`) so the HNSW ANN index never returns stale vectors.
*/
/** A single chunk row to persist for a page (page-body embeddings). */
export interface PageEmbeddingChunkRow {
pageId: string;
workspaceId: string;
spaceId: string;
// null for page-body chunks; set only for attachment chunks (future).
attachmentId: string | null;
chunkIndex: number;
chunkStart: number;
chunkLength: number;
content: string;
modelName: string;
modelDimensions: number;
embedding: number[];
}
/** A single ANN search hit. */
export interface PageEmbeddingSearchHit {
pageId: string;
spaceId: string;
title: string | null;
content: string;
// Cosine distance (0 = identical direction). Lower is more similar.
distance: number;
}
@Injectable()
export class PageEmbeddingRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
/**
* HARD-delete every embedding row for a page (within its workspace). Used
* before a reindex and on page deletion — a hard delete (not soft) guarantees
* the HNSW index never returns vectors for content that no longer exists.
*/
async deleteByPage(
pageId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pageEmbeddings')
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.execute();
}
/**
* Bulk-insert chunk rows for a page. The `embedding` value is serialized with
* `pgvector.toSql` and cast to `vector` so Postgres stores it in the fixed
* `vector(1536)` column. No-op on an empty array.
*/
async insertChunks(
rows: PageEmbeddingChunkRow[],
trx?: KyselyTransaction,
): Promise<void> {
if (rows.length === 0) return;
const db = dbOrTx(this.db, trx);
await db
.insertInto('pageEmbeddings')
.values(
rows.map((row) => ({
pageId: row.pageId,
workspaceId: row.workspaceId,
spaceId: row.spaceId,
attachmentId: row.attachmentId,
chunkIndex: row.chunkIndex,
chunkStart: row.chunkStart,
chunkLength: row.chunkLength,
content: row.content,
modelName: row.modelName,
modelDimensions: row.modelDimensions,
// pgvector.toSql -> '[1,2,3]'; cast the bound literal to vector.
embedding: sql`${pgvector.toSql(row.embedding)}::vector`,
})),
)
.execute();
}
/**
* Cosine ANN search over the embeddings, scoped to a workspace AND a set of
* spaces the caller may read (see semanticSearch access-scoping). Orders by
* `embedding <=> $query` (cosine distance) and joins the page title cheaply.
* Returns [] when `spaceIds` is empty (no accessible spaces => no results).
*/
async searchByEmbedding(
workspaceId: string,
queryEmbedding: number[],
spaceIds: string[],
limit: number,
): Promise<PageEmbeddingSearchHit[]> {
if (spaceIds.length === 0) return [];
// Serialized + cast query vector reused for the distance expression.
const queryVector = sql`${pgvector.toSql(queryEmbedding)}::vector`;
const rows = await this.db
.selectFrom('pageEmbeddings as pe')
.innerJoin('pages as p', 'p.id', 'pe.pageId')
.select([
'pe.pageId as pageId',
'pe.spaceId as spaceId',
'pe.content as content',
'p.title as title',
sql<number>`pe.embedding <=> ${queryVector}`.as('distance'),
])
.where('pe.workspaceId', '=', workspaceId)
.where('pe.spaceId', 'in', spaceIds)
// Exclude chunks whose page is in the trash (defence in depth).
.where('p.deletedAt', 'is', null)
.orderBy('distance', 'asc')
.limit(limit)
.execute();
return rows.map((row) => ({
pageId: row.pageId,
spaceId: row.spaceId,
title: row.title,
content: row.content,
distance: Number(row.distance),
}));
}
}

View File

@@ -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)