The floating chat window's header badge flipped meaning — a live per-turn token counter while streaming, the persisted context size at rest — so it "reset to 1" on each prompt and conflated two different numbers. Replace it with a stable "current / max" context badge (e.g. `572 / 200k`). The live "Thinking · N tokens" inside the chat body stays; only the duplicate live counter is removed from the header. Max comes from a new admin setting "Context window (tokens)". The server resolves it and attaches `maxContextTokens` to the completed assistant turn's metadata (next to contextTokens), so the badge needs no client-side model resolution and this survives public shares / per-role models. Server: - ai.types: chatContextWindow on AiProviderSettings + PROVIDER_SETTINGS_KEYS + ResolvedAiConfig + MaskedAiSettings. - workspace.repo: chatContextWindow in AI_PROVIDER_SETTINGS_ALLOWED (parity). - update-ai-settings.dto: @IsInt @Min(0) chatContextWindow. - ai-settings.service: coerce the ::text-stored value to a positive int in resolve()/getMasked(). - ai-chat.service: flushAssistant writes metadata.maxContextTokens (>0); the completed turn passes resolved.chatContextWindow. Client: - ai-chat.types: maxContextTokens on the message-row metadata. - ai-chat-window: read maxContextTokens; render "current [/ max]"; drop the liveTurnTokens state/branch and the onLiveTurnTokens prop; new tooltip. - chat-thread: remove the live-turn-token throttle effect and plumbing. - count-stream-tokens: drop the now-dead liveTurnTokens()/types; keep estimateTokens. - settings: chatContextWindow on IAiSettings(+Update) + a NumberInput in the AI provider settings form. i18n: add the badge/settings keys (en, ru); remove the two now-unused keys. Tests: flushAssistant maxContextTokens, DTO validation, trim token tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
import { validate } from 'class-validator';
|
|
import { plainToInstance } from 'class-transformer';
|
|
import { PROVIDER_SETTINGS_KEYS } from './ai.types';
|
|
import { AI_PROVIDER_SETTINGS_ALLOWED } from '@docmost/db/repos/workspace/workspace.repo';
|
|
import { UpdateAiSettingsDto } from './dto/update-ai-settings.dto';
|
|
|
|
/**
|
|
* Drift guard: the writable provider-settings keys are maintained in two layers
|
|
* that TypeScript cannot cross-check — PROVIDER_SETTINGS_KEYS (ai.types, used by
|
|
* the settings service) and AI_PROVIDER_SETTINGS_ALLOWED (the generic workspace
|
|
* repo's SQL boundary). A key missing from the repo copy silently drops the field
|
|
* on persist (exactly what happened to chatApiStyle), so this asserts they match.
|
|
*/
|
|
describe('provider-settings key allowlist parity', () => {
|
|
it('the repo SQL allowlist equals PROVIDER_SETTINGS_KEYS', () => {
|
|
expect([...AI_PROVIDER_SETTINGS_ALLOWED].sort()).toEqual(
|
|
[...PROVIDER_SETTINGS_KEYS].sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
/** DTO validation for the new chatApiStyle field (@IsIn(CHAT_API_STYLES)). */
|
|
describe('UpdateAiSettingsDto.chatApiStyle', () => {
|
|
const errorsFor = async (chatApiStyle: unknown) =>
|
|
validate(plainToInstance(UpdateAiSettingsDto, { chatApiStyle }));
|
|
|
|
it('accepts both valid values', async () => {
|
|
for (const v of ['openai-compatible', 'openai']) {
|
|
const errs = await errorsFor(v);
|
|
expect(errs.find((e) => e.property === 'chatApiStyle')).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('rejects an unknown value', async () => {
|
|
const errs = await errorsFor('definitely-not-a-style');
|
|
expect(errs.find((e) => e.property === 'chatApiStyle')).toBeDefined();
|
|
});
|
|
|
|
it('accepts the field being omitted (optional)', async () => {
|
|
const errs = await validate(plainToInstance(UpdateAiSettingsDto, {}));
|
|
expect(errs.find((e) => e.property === 'chatApiStyle')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
/** DTO validation for the new chatContextWindow field (@IsInt @Min(0)). */
|
|
describe('UpdateAiSettingsDto.chatContextWindow', () => {
|
|
const errorsFor = async (chatContextWindow: unknown) =>
|
|
validate(plainToInstance(UpdateAiSettingsDto, { chatContextWindow }));
|
|
|
|
it('accepts a non-negative integer (incl. 0 = clear the limit)', async () => {
|
|
for (const v of [0, 200000]) {
|
|
const errs = await errorsFor(v);
|
|
expect(
|
|
errs.find((e) => e.property === 'chatContextWindow'),
|
|
).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('rejects a negative value', async () => {
|
|
const errs = await errorsFor(-1);
|
|
expect(errs.find((e) => e.property === 'chatContextWindow')).toBeDefined();
|
|
});
|
|
|
|
it('rejects a non-integer value', async () => {
|
|
const errs = await errorsFor(1.5);
|
|
expect(errs.find((e) => e.property === 'chatContextWindow')).toBeDefined();
|
|
});
|
|
|
|
it('accepts the field being omitted (optional)', async () => {
|
|
const errs = await validate(plainToInstance(UpdateAiSettingsDto, {}));
|
|
expect(
|
|
errs.find((e) => e.property === 'chatContextWindow'),
|
|
).toBeUndefined();
|
|
});
|
|
});
|