feat(core): improve byok editing (#15427)

fix #14287
fix #15359
fix #15424

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Redesigned workspace AI provider settings with connection testing,
storage options, model selection, capability management, ordering, and
custom endpoints.
* AI chat model choices now adapt to the selected workspace and
conversation route.
  * Added support for image-based AI requests.
* **Bug Fixes**
  * Improved handling of unavailable or outdated model selections.
* App configuration updates now reject overlapping paths and load
deterministically.
* **Tests**
* Expanded coverage for provider models, AI chat scoping, image
requests, and configuration validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-08-05 19:26:19 +08:00
committed by GitHub
parent 965f4590ff
commit 543667d9b3
57 changed files with 2951 additions and 1472 deletions
@@ -144,6 +144,8 @@ test('image request builder receives only serializable request options', async t
runtime.streamImageArtifacts({}, [{ role: 'user', content: 'draw' }], {
quality: 'high',
seed: 42,
modelName: 'stabilityai/stable-diffusion-xl-base-1.0',
loras: [{ path: 'https://example.com/sketch.safetensors', scale: 1 }],
signal: controller.signal,
user: 'user-1',
})
@@ -158,6 +160,13 @@ test('image request builder receives only serializable request options', async t
outputFormat: 'webp',
seed: 42,
},
providerOptions: {
provider: 'fal',
options: {
model_name: 'stabilityai/stable-diffusion-xl-base-1.0',
loras: [{ path: 'https://example.com/sketch.safetensors', scale: 1 }],
},
},
});
});
@@ -1,4 +1,5 @@
import { faker } from '@faker-js/faker';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import Sinon from 'sinon';
@@ -14,6 +15,7 @@ const module = await createModule({
const service = module.get(ServerService);
const user = await module.create(Mockers.User);
const models = module.get(Models);
const db = module.get(PrismaClient);
test.afterEach(async () => {
Sinon.reset();
@@ -111,6 +113,40 @@ test('should revalidate config', async t => {
t.is(service.getConfig().server.externalUrl, newValue);
});
test('should reject overlapping app config paths in one update', async t => {
await t.throwsAsync(
models.appConfig.save(user.id, [
{ key: 'testOverlapRoot.branch', value: { enabled: true } },
{ key: 'testOverlapRoot.branch.enabled', value: false },
]),
{ message: /must not overlap/ }
);
});
test('should serialize concurrent overlapping app config updates', async t => {
const root = `testConcurrentOverlap.${faker.string.uuid()}`;
try {
const results = await Promise.allSettled([
models.appConfig.save(user.id, [{ key: root, value: { enabled: true } }]),
models.appConfig.save(user.id, [
{ key: `${root}.enabled`, value: false },
]),
]);
t.is(results.filter(result => result.status === 'fulfilled').length, 1);
t.is(results.filter(result => result.status === 'rejected').length, 1);
t.regex(
String(results.find(result => result.status === 'rejected')?.reason),
/must not overlap/
);
} finally {
await db.appConfig.deleteMany({
where: { id: { startsWith: root } },
});
}
});
test('should emit config changed event', async t => {
const newUrl = faker.internet.url();
@@ -9,11 +9,34 @@ export class AppConfigModel extends BaseModel {
async load(excludedKeys: string[] = []) {
return this.db.appConfig.findMany({
where: excludedKeys.length ? { id: { notIn: excludedKeys } } : undefined,
orderBy: { id: 'asc' },
});
}
@Transactional()
async save(user: string, updates: Array<{ key: string; value: any }>) {
await this.db
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'app-config-paths'}, 0))`;
const existing = await this.db.appConfig.findMany({
select: { id: true },
});
const updateKeys = updates.map(update => update.key);
for (const [index, key] of updateKeys.entries()) {
const overlappingKey = [
...existing.map(config => config.id),
...updateKeys.slice(0, index),
].find(
candidate =>
candidate !== key &&
(candidate.startsWith(`${key}.`) || key.startsWith(`${candidate}.`))
);
if (overlappingKey) {
throw new Error(
`App config paths must not overlap: ${overlappingKey} and ${key}`
);
}
}
return await Promise.allSettled(
updates.map(async update => {
return this.db.appConfig.upsert({
@@ -398,13 +398,13 @@ export class CapabilityRuntime {
_filter?: ProviderFilter,
slot = 'image.generate'
): AsyncIterableIterator<NativeImageArtifact> {
const { quality, seed } = options;
const { quality, seed, modelName, loras } = options;
const result = (await this.execute(
slot,
buildLlmImageRequestFromMessages({
model: 'route-selected',
messages: preparePromptMessagesForNativeRequest(messages, true),
options: { quality, seed },
options: { quality, seed, modelName, loras },
}),
cond,
options