From 543667d9b35a4a52e69a225c80503eeef3de5f99 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:26:19 +0800 Subject: [PATCH] feat(core): improve byok editing (#15427) fix #14287 fix #15359 fix #15424 ## 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. --- Cargo.lock | 4 +- .../native/src/llm/core/prompt/session.rs | 104 +- packages/backend/native/src/llm/mod.rs | 22 +- .../src/runtime/backend_runtime/byok/probe.rs | 3 +- .../backend_runtime/copilot/dispatch.rs | 53 +- .../runtime/backend_runtime/copilot/mod.rs | 7 +- packages/backend/native/src/runtime/config.rs | 23 +- .../copilot/capability-runtime.spec.ts | 9 + .../src/core/config/__tests__/service.spec.ts | 36 + packages/backend/server/src/models/config.ts | 23 + .../copilot/runtime/capability-runtime.ts | 4 +- .../core/src/blocksuite/ai/actions/types.ts | 1 - .../ai-chat-composer/ai-chat-composer.ts | 10 +- .../ai-chat-content/ai-chat-content.ts | 4 +- .../components/ai-chat-input/ai-chat-input.ts | 18 +- .../ai-chat-input/preference-popup.ts | 170 ++- .../ai/components/playground/chat.ts | 10 +- .../ai/components/playground/content.ts | 14 +- .../ai/peek-view/chat-block-peek-view.ts | 8 +- .../src/blocksuite/ai/runtime/chat/actions.ts | 4 +- .../ai/runtime/chat/runtime.spec.ts | 2 - .../src/blocksuite/ai/runtime/chat/runtime.ts | 7 +- .../src/blocksuite/ai/runtime/chat/state.ts | 2 +- .../ai/runtime/request/byok-local-lease.ts | 5 +- .../ai/runtime/request/copilot-client.ts | 6 + .../ai/runtime/request/message-transport.ts | 8 + .../ai/runtime/request/service.spec.ts | 15 +- .../workspace-setting/byok/add-key-modal.tsx | 602 +++++++--- .../workspace-setting/byok/coverage.tsx | 17 +- .../workspace-setting/byok/index.css.ts | 323 ++++- .../workspace-setting/byok/index.spec.tsx | 1059 +++++++---------- .../setting/workspace-setting/byok/index.tsx | 199 ++-- .../workspace-setting/byok/key-list.tsx | 20 +- .../workspace-setting/byok/local-storage.ts | 16 +- .../workspace-setting/byok/metadata.spec.ts | 34 - .../workspace-setting/byok/metadata.ts | 59 +- .../byok/model-editor-modal.tsx | 256 ++++ .../workspace-setting/byok/model-selector.tsx | 227 ++++ .../byok/model-utils.spec.ts | 46 + .../workspace-setting/byok/model-utils.ts | 150 +++ .../setting/workspace-setting/byok/types.ts | 77 +- .../desktop/pages/workspace/chat/index.tsx | 4 +- .../pages/workspace/detail-page/tabs/chat.tsx | 6 +- .../core/src/modules/ai-button/index.ts | 1 + .../modules/ai-button/services/models.spec.ts | 67 ++ .../src/modules/ai-button/services/models.ts | 161 ++- .../audio-transcription-job-store.spec.ts | 7 +- .../view/ai-chat-block-peek-view/index.tsx | 8 +- .../i18n/src/i18n-completenesses.json | 46 +- packages/frontend/i18n/src/i18n.gen.ts | 289 ++++- packages/frontend/i18n/src/resources/de.json | 16 - packages/frontend/i18n/src/resources/en.json | 81 +- packages/frontend/i18n/src/resources/kk.json | 16 - packages/frontend/i18n/src/resources/tr.json | 16 - packages/frontend/i18n/src/resources/ur.json | 16 - .../frontend/i18n/src/resources/zh-Hans.json | 20 +- .../affine-cloud-copilot/playwright.config.ts | 12 +- 57 files changed, 2951 insertions(+), 1472 deletions(-) delete mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.spec.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-editor-modal.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-selector.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.spec.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.ts create mode 100644 packages/frontend/core/src/modules/ai-button/services/models.spec.ts diff --git a/Cargo.lock b/Cargo.lock index e663066ed4..ca1ebb3f4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4753,9 +4753,9 @@ dependencies = [ [[package]] name = "llm_adapter" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4086072a8f69a2a119e187367844d542f133f1bdf96ebdf495b49b83cf4a05" +checksum = "5cfb4eab8636c2e3ac87a221630f6b458e1a7ffb32e3701576840871b98d1bc6" dependencies = [ "base64", "jsonschema", diff --git a/packages/backend/native/src/llm/core/prompt/session.rs b/packages/backend/native/src/llm/core/prompt/session.rs index 1f5d0697d3..9fe6c8b616 100644 --- a/packages/backend/native/src/llm/core/prompt/session.rs +++ b/packages/backend/native/src/llm/core/prompt/session.rs @@ -135,9 +135,25 @@ fn estimated_message_bytes(message: &PromptMessageContract) -> usize { let mut size = MESSAGE_FRAMING_BYTES .saturating_add(message.role.len()) .saturating_add(message.content.len()); + if let Some(attachments) = &message.attachments { + size = attachments.iter().fold(size, |size, attachment| { + size.saturating_add( + serde_json::to_vec(&attachment_metadata(attachment)) + .map(|bytes| bytes.len()) + .unwrap_or(usize::MAX), + ) + }); + } for value in [ - message.attachments.as_ref().map(serde_json::to_vec), - message.params.as_ref().map(serde_json::to_vec), + message.params.as_ref().map(|params| { + let mut metadata = params.clone(); + if let Some(Value::Array(attachments)) = metadata.get_mut("attachments") { + for attachment in attachments { + *attachment = attachment_metadata(attachment); + } + } + serde_json::to_vec(&metadata) + }), message.response_format.as_ref().map(serde_json::to_vec), ] .into_iter() @@ -148,6 +164,32 @@ fn estimated_message_bytes(message: &PromptMessageContract) -> usize { size } +fn attachment_metadata(attachment: &Value) -> Value { + if attachment.as_str().is_some_and(|value| value.starts_with("data:")) { + return Value::String("data:".to_string()); + } + let Some(object) = attachment.as_object() else { + return attachment.clone(); + }; + let inline = matches!(object.get("kind").and_then(Value::as_str), Some("data" | "bytes")); + let metadata = object + .iter() + .map(|(key, value)| { + let value = if inline && key == "data" { + Value::Null + } else if matches!(key.as_str(), "url" | "attachment") + && value.as_str().is_some_and(|value| value.starts_with("data:")) + { + Value::String("data:".to_string()) + } else { + value.clone() + }; + (key.clone(), value) + }) + .collect::>(); + Value::Object(metadata) +} + fn select_history_turns( fixed_messages: &[PromptMessageContract], history: &[PromptMessageContract], @@ -266,6 +308,64 @@ mod tests { let emoji = estimated_message_bytes(&message("user", "😀😀😀")); assert!(ascii < cjk); assert!(cjk < emoji); + + let small = serde_json::from_value(json!({ + "role": "user", + "content": "describe", + "attachments": [{ "kind": "bytes", "data": "aW1n", "mimeType": "image/png" }] + })) + .unwrap(); + let large = serde_json::from_value(json!({ + "role": "user", + "content": "describe", + "attachments": [{ "kind": "bytes", "data": "aW1n".repeat(100_000), "mimeType": "image/png" }] + })) + .unwrap(); + assert_eq!(estimated_message_bytes(&small), estimated_message_bytes(&large)); + + let legacy_small = serde_json::from_value(json!({ + "role": "user", + "content": "describe", + "attachments": ["data:image/png;base64,aW1n"] + })) + .unwrap(); + let legacy_large = serde_json::from_value(json!({ + "role": "user", + "content": "describe", + "attachments": [format!("data:image/png;base64,{}", "aW1n".repeat(100_000))] + })) + .unwrap(); + assert_eq!( + estimated_message_bytes(&legacy_small), + estimated_message_bytes(&legacy_large) + ); + + let params_small = serde_json::from_value(json!({ + "role": "user", + "content": "describe", + "params": { + "attachments": [{ + "attachment": "data:image/png;base64,aW1n", + "mimeType": "image/png" + }] + } + })) + .unwrap(); + let params_large = serde_json::from_value(json!({ + "role": "user", + "content": "describe", + "params": { + "attachments": [{ + "attachment": format!("data:image/png;base64,{}", "aW1n".repeat(100_000)), + "mimeType": "image/png" + }] + } + })) + .unwrap(); + assert_eq!( + estimated_message_bytes(¶ms_small), + estimated_message_bytes(¶ms_large) + ); } #[test] diff --git a/packages/backend/native/src/llm/mod.rs b/packages/backend/native/src/llm/mod.rs index 731332d6b6..72e0e3e2d2 100644 --- a/packages/backend/native/src/llm/mod.rs +++ b/packages/backend/native/src/llm/mod.rs @@ -6,6 +6,16 @@ mod ffi; mod prompt_catalog; pub(crate) mod route; +pub use action::copilot_action_recipe; +pub use byok::{ + ByokCapabilityInput, ByokCatalogModelOutput, ByokCatalogOutput, ByokCatalogProviderOutput, ByokEndpointInput, + ByokLocalLeaseOutput, ByokModelDeclarationInput, ByokModelProbeCheckOutput, ByokModelProbeOutput, + ByokProbeCheckInput, ByokProbeResultOutput, ByokProbeStatusOutput, ByokProfileDefinitionInput, ByokProfileOutput, + ByokValidationOutput, CreateByokLocalLeaseInput, CreateByokLocalLeaseProviderInput, CreateByokProfileInput, + ProbeByokDraftInput, ProbeByokProfileInput, ReorderByokProfilesInput, ReplaceByokProfileInput, + RotateByokCredentialInput, byok_catalog, +}; + pub use self::core::{ capability::llm_match_model_capabilities, model_registry::{llm_match_model_registry, llm_resolve_model_registry_variant}, @@ -20,16 +30,6 @@ pub use self::core::{ structured_output::{llm_canonical_json_schema_hash, llm_validate_json_schema}, }; -pub use action::copilot_action_recipe; -pub use byok::{ - ByokCapabilityInput, ByokCatalogModelOutput, ByokCatalogOutput, ByokCatalogProviderOutput, ByokEndpointInput, - ByokLocalLeaseOutput, ByokModelDeclarationInput, ByokModelProbeCheckOutput, ByokModelProbeOutput, - ByokProbeCheckInput, ByokProbeResultOutput, ByokProbeStatusOutput, ByokProfileDefinitionInput, ByokProfileOutput, - ByokValidationOutput, CreateByokLocalLeaseInput, CreateByokLocalLeaseProviderInput, CreateByokProfileInput, - ProbeByokDraftInput, ProbeByokProfileInput, ReorderByokProfilesInput, ReplaceByokProfileInput, - RotateByokCredentialInput, byok_catalog, -}; - #[napi_derive::napi(catch_unwind)] pub fn llm_get_byok_catalog() -> ByokCatalogOutput { byok_catalog() @@ -44,6 +44,8 @@ pub use route::{ CopilotAccessProjection, CopilotExecuteInput, CopilotManagedTier, CopilotRouteCheckInput, CopilotTargetOverrideInput, }; +pub(crate) use self::core::contracts::LlmImageRequestContract; + pub(crate) fn invalid_arg(message: impl Into) -> napi::Error { napi::Error::new(napi::Status::InvalidArg, message.into()) } diff --git a/packages/backend/native/src/runtime/backend_runtime/byok/probe.rs b/packages/backend/native/src/runtime/backend_runtime/byok/probe.rs index a6c8adc655..84549bad0e 100644 --- a/packages/backend/native/src/runtime/backend_runtime/byok/probe.rs +++ b/packages/backend/native/src/runtime/backend_runtime/byok/probe.rs @@ -432,9 +432,10 @@ fn backend_error_kind(error: &BackendError) -> &'static str { #[cfg(test)] mod tests { - use super::*; use llm_adapter::target::BackendEndpoint; + use super::*; + #[test] fn connection_probe_errors_are_low_information() { let (url, headers) = probe_request("openai", &ByokEndpoint::ProviderDefault, "secret".to_string()); diff --git a/packages/backend/native/src/runtime/backend_runtime/copilot/dispatch.rs b/packages/backend/native/src/runtime/backend_runtime/copilot/dispatch.rs index d59ea88aaa..a8d0c15253 100644 --- a/packages/backend/native/src/runtime/backend_runtime/copilot/dispatch.rs +++ b/packages/backend/native/src/runtime/backend_runtime/copilot/dispatch.rs @@ -22,6 +22,7 @@ use zeroize::Zeroizing; use super::{COPILOT_REQUEST_TIMEOUT, RuntimeError, RuntimeResult, context}; use crate::{ llm::{ + LlmImageRequestContract, byok::{ByokEndpoint, CredentialEnvelopeKey}, route::{ AuthorizedProfileRef, AuthorizedTargetRef, CatalogSlot, CredentialRef, RouteOperation, with_request_requirements, @@ -83,7 +84,14 @@ pub(super) fn request_and_slot( } RouteOperation::Embedding => ExecutableRequest::Embedding(parse_request(request)?), RouteOperation::Rerank => ExecutableRequest::Rerank(parse_request(request)?), - RouteOperation::Image => ExecutableRequest::Image(Box::new(parse_request(request)?)), + RouteOperation::Image => { + let request = parse_request::(request)?; + ExecutableRequest::Image(Box::new( + request + .try_into() + .map_err(|error: napi::Error| RuntimeError::invalid_input(error.reason.clone()))?, + )) + } }; let (needs_tools, attachment_kinds, attachment_sources) = request_requirements(&executable); Ok(( @@ -405,3 +413,46 @@ impl From for RuntimeError { RuntimeError::invalid_state(error.to_string()) } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::llm::route::slot; + + #[test] + fn image_dispatch_accepts_napi_request_contract() { + let (_, request) = request_and_slot( + slot("action.image.filter.pixel").unwrap(), + json!({ + "model": "gpt-image-1", + "prompt": "apply pixel filter", + "operation": "edit", + "images": [{ + "kind": "data", + "dataBase64": "aW1n", + "mediaType": "image/png", + "fileName": "in.png" + }], + "options": { + "outputFormat": "webp", + "outputCompression": 80 + }, + "providerOptions": { + "provider": "openai", + "options": { + "input_fidelity": "high" + } + } + }), + ) + .unwrap(); + + let ExecutableRequest::Image(request) = request else { + panic!("image slot produced a non-image request"); + }; + assert!(request.is_edit()); + assert_eq!(request.images()[0].media_type(), Some("image/png")); + } +} diff --git a/packages/backend/native/src/runtime/backend_runtime/copilot/mod.rs b/packages/backend/native/src/runtime/backend_runtime/copilot/mod.rs index 6ca7528dff..e21272edcc 100644 --- a/packages/backend/native/src/runtime/backend_runtime/copilot/mod.rs +++ b/packages/backend/native/src/runtime/backend_runtime/copilot/mod.rs @@ -8,15 +8,14 @@ use std::{ time::Duration, }; +pub(in crate::runtime::backend_runtime) use dispatch::{ + endpoint as byok_endpoint, protocol as executable_protocol, provider as backend_provider, +}; use gcp_auth::TokenProvider; use sha2::{Digest, Sha256}; use tokio::sync::OnceCell; use zeroize::Zeroizing; -pub(in crate::runtime::backend_runtime) use dispatch::{ - endpoint as byok_endpoint, protocol as executable_protocol, provider as backend_provider, -}; - use super::{BackendRuntime, RuntimeError, RuntimeResult, to_napi_error}; use crate::{ llm::{ diff --git a/packages/backend/native/src/runtime/config.rs b/packages/backend/native/src/runtime/config.rs index 854c4cc7f6..685b8b296d 100644 --- a/packages/backend/native/src/runtime/config.rs +++ b/packages/backend/native/src/runtime/config.rs @@ -330,7 +330,10 @@ fn default_mail_class_mapping() -> BTreeMap { } async fn load_app_config_overrides_from_db(pool: &PgPool) -> RuntimeResult { - let rows = match sqlx::query("SELECT id, value FROM app_configs").fetch_all(pool).await { + let rows = match sqlx::query("SELECT id, value FROM app_configs ORDER BY id ASC") + .fetch_all(pool) + .await + { Ok(rows) => rows, Err(sqlx::Error::Database(err)) if err.code().as_deref() == Some("42P01") => { return Ok(serde_json::Value::Object(Map::new())); @@ -360,6 +363,8 @@ where S: AsRef, { let mut root = Map::new(); + let mut rows = rows.into_iter().collect::>(); + rows.sort_by(|(left, _), (right, _)| left.as_ref().cmp(right.as_ref())); for (path, value) in rows { insert_flat_override(&mut root, path.as_ref(), value); } @@ -512,6 +517,22 @@ mod tests { assert_eq!(copilot.providers.profiles[0].id, "managed-openai"); } + #[test] + fn nested_database_config_overrides_are_order_independent() { + let app_config = app_config_from_flat_overrides([ + ("copilot.byok.enabled", serde_json::json!(false)), + ( + "copilot.byok", + serde_json::json!({ "enabled": true, "allowCustomEndpoint": true }), + ), + ]) + .unwrap(); + let byok = app_config.copilot.unwrap().byok; + + assert!(!byok.enabled); + assert!(byok.allow_custom_endpoint); + } + #[test] fn database_config_only_replaces_an_active_private_key_explicitly() { let active = BackendRuntimeConfig { diff --git a/packages/backend/server/src/__tests__/copilot/capability-runtime.spec.ts b/packages/backend/server/src/__tests__/copilot/capability-runtime.spec.ts index c6cf1ba1bb..587120ba11 100644 --- a/packages/backend/server/src/__tests__/copilot/capability-runtime.spec.ts +++ b/packages/backend/server/src/__tests__/copilot/capability-runtime.spec.ts @@ -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 }], + }, + }, }); }); diff --git a/packages/backend/server/src/core/config/__tests__/service.spec.ts b/packages/backend/server/src/core/config/__tests__/service.spec.ts index 52fcc2d81e..a130bc2a5f 100644 --- a/packages/backend/server/src/core/config/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/config/__tests__/service.spec.ts @@ -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(); diff --git a/packages/backend/server/src/models/config.ts b/packages/backend/server/src/models/config.ts index 2aa2de0f91..5534b5897b 100644 --- a/packages/backend/server/src/models/config.ts +++ b/packages/backend/server/src/models/config.ts @@ -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({ diff --git a/packages/backend/server/src/plugins/copilot/runtime/capability-runtime.ts b/packages/backend/server/src/plugins/copilot/runtime/capability-runtime.ts index b9c2c60d77..38e695d6b5 100644 --- a/packages/backend/server/src/plugins/copilot/runtime/capability-runtime.ts +++ b/packages/backend/server/src/plugins/copilot/runtime/capability-runtime.ts @@ -398,13 +398,13 @@ export class CapabilityRuntime { _filter?: ProviderFilter, slot = 'image.generate' ): AsyncIterableIterator { - 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 diff --git a/packages/frontend/core/src/blocksuite/ai/actions/types.ts b/packages/frontend/core/src/blocksuite/ai/actions/types.ts index b957b70f62..73be7b828e 100644 --- a/packages/frontend/core/src/blocksuite/ai/actions/types.ts +++ b/packages/frontend/core/src/blocksuite/ai/actions/types.ts @@ -367,7 +367,6 @@ declare global { // TODO(@Peng): should be refactored to get rid of implement details (like messages, action, role, etc.) interface AIHistory { sessionId: string; - tokens: number; action: string | null; createdAt: string; messages: { diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts index 3680ac949c..73cbfb92f3 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts @@ -2,9 +2,9 @@ import './ai-chat-composer-tip'; import type { AIDraftService, + AIModelService, AIToolsConfigService, } from '@affine/core/modules/ai-button'; -import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; import type { ServerService, SubscriptionService, @@ -128,15 +128,15 @@ export class AIChatComposer extends SignalWatcher( @property({ attribute: false }) accessor aiToolsConfigService!: AIToolsConfigService; + @property({ attribute: false }) + accessor aiModelService!: AIModelService; + @property({ attribute: false }) accessor affineFeatureFlagService!: FeatureFlagService; @property({ attribute: false }) accessor subscriptionService!: SubscriptionService; - @property({ attribute: false }) - accessor aiModelService!: AIModelService; - @property({ attribute: false }) accessor onAISubscribe!: () => Promise; @@ -183,9 +183,9 @@ export class AIChatComposer extends SignalWatcher( .affineFeatureFlagService=${this.affineFeatureFlagService} .aiDraftService=${this.aiDraftService} .aiToolsConfigService=${this.aiToolsConfigService} + .aiModelService=${this.aiModelService} .notificationService=${this.notificationService} .subscriptionService=${this.subscriptionService} - .aiModelService=${this.aiModelService} .onAISubscribe=${this.onAISubscribe} .portalContainer=${this.portalContainer} .onChatSuccess=${this.onChatSuccess} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-content/ai-chat-content.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-content/ai-chat-content.ts index 7402424f26..82d36afe46 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-content/ai-chat-content.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-content/ai-chat-content.ts @@ -1,9 +1,9 @@ import type { AIDraftService, + AIModelService, AIToolsConfigService, } from '@affine/core/modules/ai-button'; import type { AIDraftState } from '@affine/core/modules/ai-button/services/ai-draft'; -import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; import type { ServerService, SubscriptionService, @@ -404,8 +404,8 @@ export class AIChatContent extends SignalWatcher( .notificationService=${this.notificationService} .aiDraftService=${this.aiDraftService} .aiToolsConfigService=${this.aiToolsConfigService} - .subscriptionService=${this.subscriptionService} .aiModelService=${this.aiModelService} + .subscriptionService=${this.subscriptionService} .onAISubscribe=${this.onAISubscribe} .trackOptions=${{ where: 'chat-panel', diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts index 2d2dcab6f8..bcb8ea7e11 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts @@ -1,8 +1,8 @@ import type { AIDraftService, + AIModelService, AIToolsConfigService, } from '@affine/core/modules/ai-button'; -import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; import type { ServerService, SubscriptionService, @@ -399,6 +399,9 @@ export class AIChatInput extends SignalWatcher( @property({ attribute: false }) accessor aiToolsConfigService!: AIToolsConfigService; + @property({ attribute: false }) + accessor aiModelService!: AIModelService; + @property({ attribute: false }) accessor affineFeatureFlagService!: FeatureFlagService; @@ -408,9 +411,6 @@ export class AIChatInput extends SignalWatcher( @property({ attribute: false }) accessor subscriptionService!: SubscriptionService; - @property({ attribute: false }) - accessor aiModelService!: AIModelService; - @property({ attribute: false }) accessor onAISubscribe!: () => Promise; @@ -483,6 +483,12 @@ export class AIChatInput extends SignalWatcher( window.addEventListener('dragend', this._resetDragState); } + protected override updated(changedProperties: PropertyValues) { + if (changedProperties.has('workspaceId')) { + this.aiModelService.setScope(this.workspaceId, 'Chat With AFFiNE AI'); + } + } + protected override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); if (this.aiDraftService) { @@ -631,9 +637,9 @@ export class AIChatInput extends SignalWatcher( .onExtendedThinkingChange=${this._toggleReasoning} .serverService=${this.serverService} .toolsConfigService=${this.aiToolsConfigService} + .aiModelService=${this.aiModelService} .notificationService=${this.notificationService} .subscriptionService=${this.subscriptionService} - .aiModelService=${this.aiModelService} .onAISubscribe=${this.onAISubscribe} > ${status === 'transmitting' || status === 'loading' @@ -858,7 +864,7 @@ export class AIChatInput extends SignalWatcher( control: this.trackOptions?.control, reasoning: this._isReasoningActive, toolsConfig: this.aiToolsConfigService.config.value, - modelId: this.aiModelService.modelId.value, + routeTargetId: this.aiModelService.modelId.value, userInfo: { userId: userInfo?.id, userName: userInfo?.name, diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/preference-popup.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/preference-popup.ts index 0bc546ec42..99b59a0ec5 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/preference-popup.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/preference-popup.ts @@ -1,14 +1,5 @@ import type { AIToolsConfigService } from '@affine/core/modules/ai-button'; import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; -import type { - ServerService, - SubscriptionService, -} from '@affine/core/modules/cloud'; -import { - type CopilotChatHistoryFragment, - ServerDeploymentType, - SubscriptionStatus, -} from '@affine/graphql'; import { menu, popMenu, @@ -70,10 +61,10 @@ export class ChatInputPreference extends SignalWatcher( min-width: 220px; } .ai-active-model-name { - font-size: 14px; - color: ${unsafeCSSVarV2('text/secondary')}; - line-height: 22px; margin-left: 40px; + color: ${unsafeCSSVarV2('text/secondary')}; + font-size: 14px; + line-height: 22px; } .ai-model-prefix { width: 20px; @@ -82,21 +73,21 @@ export class ChatInputPreference extends SignalWatcher( .ai-model-prefix svg { color: ${unsafeCSSVarV2('icon/activated')}; } + .ai-model-postfix { + width: 20px; + height: 20px; + } .ai-model-postfix svg:hover { color: ${unsafeCSSVarV2('icon/activated')}; } .ai-model-version { - font-size: 12px; - color: ${unsafeCSSVarV2('text/tertiary')}; - line-height: 20px; margin-right: 40px; + color: ${unsafeCSSVarV2('text/tertiary')}; + font-size: 12px; + line-height: 20px; } `; - @property({ attribute: false }) - accessor session!: CopilotChatHistoryFragment | null | undefined; - // --------- model props end --------- - // --------- extended thinking props start --------- @property({ attribute: false }) accessor extendedThinking: boolean = false; @@ -107,90 +98,91 @@ export class ChatInputPreference extends SignalWatcher( | undefined; // --------- extended thinking props end --------- - @property({ attribute: false }) - accessor serverService!: ServerService; - @property({ attribute: false }) accessor toolsConfigService!: AIToolsConfigService; - @property({ attribute: false }) - accessor notificationService!: NotificationService; - - @property({ attribute: false }) - accessor subscriptionService!: SubscriptionService; - @property({ attribute: false }) accessor aiModelService!: AIModelService; + @property({ attribute: false }) + accessor notificationService!: NotificationService; + @property({ attribute: false }) accessor onAISubscribe!: () => Promise; - model = computed(() => { - const modelId = this.aiModelService.modelId.value; - const activeModel = this.aiModelService.models.value.find( - model => model.id === modelId - ); - const defaultModel = this.aiModelService.models.value.find( - model => model.isDefault - ); - return activeModel || defaultModel; - }); + private readonly model = computed(() => + this.aiModelService.models.value.find( + model => model.id === this.aiModelService.modelId.value + ) + ); openPreference(e: Event) { const element = e.currentTarget; if (!(element instanceof HTMLElement)) return; - const modelItems = []; + const preferenceItems = []; const searchItems = []; - // model switch - modelItems.push( - menu.subMenu({ - name: 'Model', - prefix: AiOutlineIcon(), - middleware: modelSubMenuMiddleware, - postfix: html` - ${this.model.value?.name} - `, - options: { - items: this.aiModelService.models.value.map(model => { - const isSelected = model.id === this.model.value?.id; - const isSelfHosted = - this.serverService.server.config$.value?.type === - ServerDeploymentType.Selfhosted; - const status = - this.subscriptionService.subscription.ai$.value?.status; - const isSubscribed = status === SubscriptionStatus.Active; - return menu.action({ - name: model.category, - info: html` - ${model.version} - `, - prefix: html` -
- ${isSelected ? DoneIcon() : undefined} -
- `, - postfix: html` -
- ${model.isPro && !isSubscribed ? LockIcon() : undefined} -
- `, - select: () => { - if (model.isPro && !isSelfHosted && !isSubscribed) { - this.notificationService.toast( - `Pro models require an AFFiNE AI subscription.` - ); - return; - } - this.aiModelService.setModel(model.id); - }, - }); - }), - }, - }) - ); + if (this.aiModelService.models.value.length) { + preferenceItems.push( + menu.subMenu({ + name: 'Model', + prefix: AiOutlineIcon(), + middleware: modelSubMenuMiddleware, + postfix: html` + + ${this.model.value?.name ?? 'Auto'} + + `, + options: { + items: [ + menu.action({ + name: 'Auto', + prefix: html` +
+ ${this.aiModelService.modelId.value + ? undefined + : DoneIcon()} +
+ `, + select: () => this.aiModelService.resetModel(), + }), + ...this.aiModelService.models.value.map(model => + menu.action({ + name: model.category, + info: html` + ${model.version} + `, + prefix: html` +
+ ${model.id === this.aiModelService.modelId.value + ? DoneIcon() + : undefined} +
+ `, + postfix: html` +
+ ${model.available ? undefined : LockIcon()} +
+ `, + select: () => { + if (!model.available) { + this.notificationService.toast( + 'This model requires an AFFiNE AI subscription.' + ); + this.onAISubscribe().catch(console.error); + return; + } + this.aiModelService.setModel(model.id); + }, + }) + ), + ], + }, + }) + ); + } - modelItems.push( + preferenceItems.push( menu.toggleSwitch({ name: 'Extended Thinking', prefix: ThinkingIcon(), @@ -220,7 +212,7 @@ export class ChatInputPreference extends SignalWatcher( options: { items: [ menu.group({ - items: [...modelItems], + items: [...preferenceItems], }), menu.group({ items: [...searchItems], @@ -238,7 +230,7 @@ export class ChatInputPreference extends SignalWatcher( class="chat-input-preference-trigger" > - ${this.model.value?.category} + ${this.model.value?.category ?? 'Auto'} ${ArrowDownSmallIcon()} diff --git a/packages/frontend/core/src/blocksuite/ai/components/playground/chat.ts b/packages/frontend/core/src/blocksuite/ai/components/playground/chat.ts index 00aa2e0780..e1a681fcd6 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/playground/chat.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/playground/chat.ts @@ -1,5 +1,7 @@ -import type { AIToolsConfigService } from '@affine/core/modules/ai-button'; -import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; +import type { + AIModelService, + AIToolsConfigService, +} from '@affine/core/modules/ai-button'; import type { ServerService, SubscriptionService, @@ -185,10 +187,10 @@ export class PlaygroundChat extends SignalWatcher( accessor aiToolsConfigService!: AIToolsConfigService; @property({ attribute: false }) - accessor subscriptionService!: SubscriptionService; + accessor aiModelService!: AIModelService; @property({ attribute: false }) - accessor aiModelService!: AIModelService; + accessor subscriptionService!: SubscriptionService; @property({ attribute: false }) accessor onAISubscribe: (() => Promise) | undefined; diff --git a/packages/frontend/core/src/blocksuite/ai/components/playground/content.ts b/packages/frontend/core/src/blocksuite/ai/components/playground/content.ts index b10332594b..886325c42c 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/playground/content.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/playground/content.ts @@ -1,5 +1,7 @@ -import type { AIToolsConfigService } from '@affine/core/modules/ai-button'; -import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; +import type { + AIModelService, + AIToolsConfigService, +} from '@affine/core/modules/ai-button'; import type { ServerService, SubscriptionService, @@ -103,15 +105,15 @@ export class PlaygroundContent extends SignalWatcher( @property({ attribute: false }) accessor aiToolsConfigService!: AIToolsConfigService; + @property({ attribute: false }) + accessor aiModelService!: AIModelService; + @property({ attribute: false }) accessor affineWorkspaceDialogService!: WorkspaceDialogService; @property({ attribute: false }) accessor subscriptionService!: SubscriptionService; - @property({ attribute: false }) - accessor aiModelService!: AIModelService; - @state() accessor sessions: CopilotChatHistoryFragment[] = []; @@ -380,10 +382,10 @@ export class PlaygroundContent extends SignalWatcher( .affineThemeService=${this.affineThemeService} .notificationService=${this.notificationService} .aiToolsConfigService=${this.aiToolsConfigService} + .aiModelService=${this.aiModelService} .affineWorkspaceDialogService=${this .affineWorkspaceDialogService} .subscriptionService=${this.subscriptionService} - .aiModelService=${this.aiModelService} .addChat=${this.addChat} > diff --git a/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts b/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts index 8132f0d038..8e18ec81d1 100644 --- a/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts +++ b/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts @@ -1,8 +1,8 @@ import type { AIDraftService, + AIModelService, AIToolsConfigService, } from '@affine/core/modules/ai-button'; -import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; import type { ServerService, SubscriptionService, @@ -584,6 +584,7 @@ export class AIChatBlockPeekView extends LitElement { .affineWorkspaceDialogService=${this.affineWorkspaceDialogService} .notificationService=${notificationService} .aiToolsConfigService=${this.aiToolsConfigService} + .aiModelService=${this.aiModelService} .affineFeatureFlagService=${this.affineFeatureFlagService} .onChatSuccess=${this._onChatSuccess} .trackOptions=${{ @@ -594,7 +595,6 @@ export class AIChatBlockPeekView extends LitElement { .reasoningConfig=${this.reasoningConfig} .serverService=${this.serverService} .subscriptionService=${this.subscriptionService} - .aiModelService=${this.aiModelService} .onAISubscribe=${this.onAISubscribe} > `; @@ -681,8 +681,8 @@ export const AIChatBlockPeekViewTemplate = ( affineWorkspaceDialogService: WorkspaceDialogService, aiDraftService: AIDraftService, aiToolsConfigService: AIToolsConfigService, - subscriptionService: SubscriptionService, aiModelService: AIModelService, + subscriptionService: SubscriptionService, onAISubscribe: (() => Promise) | undefined ) => { return html``; }; diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/chat/actions.ts b/packages/frontend/core/src/blocksuite/ai/runtime/chat/actions.ts index 6d71546639..8361046c9f 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/chat/actions.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/chat/actions.ts @@ -18,7 +18,7 @@ export type AIChatSendOptions = { control?: BlockSuitePresets.TrackerControl; reasoning?: boolean; toolsConfig?: unknown; - modelId?: string; + routeTargetId?: string; userInfo?: { userId?: string; userName?: string; @@ -45,7 +45,7 @@ export type AIChatAction = | { type: 'clearError' } | { type: 'setComposerText'; text: string } | { type: 'setReasoning'; reasoning: boolean } - | { type: 'setModel'; modelId?: string } + | { type: 'setRouteTarget'; routeTargetId?: string } | { type: 'addAttachment'; attachment: string | Blob | File } | { type: 'removeAttachment'; index: number } | { type: 'addContextItem'; item: AIChatContextItem } diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.spec.ts b/packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.spec.ts index c5e73788c4..89c2e1c4b3 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.spec.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.spec.ts @@ -34,8 +34,6 @@ function session( parentSessionId: null, promptName: 'Chat With AFFiNE AI', action: null, - optionalModels: null, - tokens: 0, ...overrides, } as CopilotChatHistoryFragment; } diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.ts b/packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.ts index c1c3473de4..3e48703913 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.ts @@ -152,8 +152,8 @@ export class AIChatRuntime { case 'setReasoning': this.updateComposer({ reasoning: action.reasoning }); return; - case 'setModel': - this.updateComposer({ modelId: action.modelId }); + case 'setRouteTarget': + this.updateComposer({ routeTargetId: action.routeTargetId }); return; case 'addAttachment': this.updateComposer({ @@ -388,7 +388,8 @@ export class AIChatRuntime { contextId: this.snapshot.composer.context.contextId, reasoning: options.reasoning ?? this.snapshot.composer.reasoning, toolsConfig: options.toolsConfig ?? this.snapshot.composer.toolsConfig, - modelId: options.modelId ?? this.snapshot.composer.modelId, + routeTargetId: + options.routeTargetId ?? this.snapshot.composer.routeTargetId, isRootSession: options.isRootSession, where: options.where, control: options.control, diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/chat/state.ts b/packages/frontend/core/src/blocksuite/ai/runtime/chat/state.ts index 0840e427a9..bb0787eab3 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/chat/state.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/chat/state.ts @@ -136,7 +136,7 @@ export type AIChatComposerState = { context: AIChatContextState; reasoning: boolean; toolsConfig?: AIToolsConfig; - modelId?: string; + routeTargetId?: string; }; export type AIChatNavigationRequest = { diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/request/byok-local-lease.ts b/packages/frontend/core/src/blocksuite/ai/runtime/request/byok-local-lease.ts index e6a3bb2b32..76bfbe00e5 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/request/byok-local-lease.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/request/byok-local-lease.ts @@ -67,9 +67,8 @@ export async function createWorkspaceByokLocalLease( provider: gqlProvider, name: provider.name, description: provider.description ?? null, - apiKey: provider.apiKey, - endpoint: provider.endpoint ?? null, - sortOrder: provider.sortOrder ?? 0, + credential: provider.credential, + definition: provider.definition, enabled: provider.enabled ?? true, }, ] diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.ts b/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.ts index e82444884b..978de2c377 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.ts @@ -467,7 +467,9 @@ export class CopilotClient { sessionId, messageId, reasoning, + profileId, modelId, + routeTargetId, toolsConfig, actionId, actionVersion, @@ -478,7 +480,9 @@ export class CopilotClient { sessionId: string; messageId?: string; reasoning?: boolean; + profileId?: string; modelId?: string; + routeTargetId?: string; toolsConfig?: AIToolsConfig; actionId?: string; actionVersion?: string; @@ -495,7 +499,9 @@ export class CopilotClient { const queryString = this.paramsToQueryString({ messageId, reasoning, + profileId, modelId, + routeTargetId, toolsConfig, actionId, actionVersion, diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/request/message-transport.ts b/packages/frontend/core/src/blocksuite/ai/runtime/request/message-transport.ts index 09e73cb2d9..9a5019272d 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/request/message-transport.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/request/message-transport.ts @@ -24,7 +24,9 @@ export type TextToTextOptions = { runId?: string; isRootSession?: boolean; reasoning?: boolean; + profileId?: string; modelId?: string; + routeTargetId?: string; toolsConfig?: AIToolsConfig; }; @@ -127,7 +129,9 @@ export function textToText({ actionVersion, runId, reasoning, + profileId, modelId, + routeTargetId, toolsConfig, }: TextToTextOptions) { let messageId: string | undefined; @@ -161,7 +165,9 @@ export function textToText({ sessionId, messageId, reasoning, + profileId, modelId, + routeTargetId, toolsConfig, actionId, actionVersion, @@ -229,7 +235,9 @@ export function textToText({ sessionId, messageId, reasoning, + profileId, modelId, + routeTargetId, toolsConfig, actionId, actionVersion, diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/request/service.spec.ts b/packages/frontend/core/src/blocksuite/ai/runtime/request/service.spec.ts index f1ce035074..0e9f6b8248 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/request/service.spec.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/request/service.spec.ts @@ -23,9 +23,13 @@ const electronApis = vi.hoisted(() => ({ Array<{ provider: string; name: string; - apiKey: string; + credential: string; + definition: { + version: number; + endpoint: { kind: string; url?: string | null }; + models: unknown[]; + }; description?: string | null; - endpoint?: string | null; sortOrder?: number | null; enabled?: boolean | null; }> @@ -117,7 +121,12 @@ describe('runtime request transport BYOK local lease handling', () => { { provider: 'openai', name: 'OpenAI', - apiKey: 'sk-local', + credential: 'sk-local', + definition: { + version: 1, + endpoint: { kind: 'provider_default' }, + models: [{ modelId: 'model-1', capabilities: [] }], + }, }, ]), }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx index 6a9bb64a73..7300c8e20b 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx @@ -1,12 +1,12 @@ -import { Button, Modal, notify } from '@affine/component'; +import { Button, Input, Modal, notify } from '@affine/component'; import { - ByokKeyStorage, ByokProvider, - testWorkspaceByokConfigMutation as testByokMutation, - upsertWorkspaceByokConfigMutation as upsertByokMutation, + createWorkspaceByokProfileMutation, + probeWorkspaceByokDraftMutation, + replaceWorkspaceByokProfileMutation, } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { logByokError } from './errors'; import * as styles from './index.css'; @@ -18,13 +18,16 @@ import { shouldShowEndpoint, storageLabel, } from './metadata'; -import type { - ByokKey, - ByokSettings, - ByokStorage, - ByokTestResult, - GqlFn, -} from './types'; +import { ModelSelector } from './model-selector'; +import { + catalogModels, + defaultModels, + type ModelDeclaration, + modelUseCases, + probeChecks, +} from './model-utils'; +import type { ByokDefinition, ByokKey, ByokSettings, GqlFn } from './types'; +import { ByokStorage } from './types'; export const AddKeyModal = ({ workspaceId, @@ -59,101 +62,133 @@ export const AddKeyModal = ({ const [provider, setProvider] = useState(ByokProvider.openai); const [name, setName] = useState(''); const [description, setDescription] = useState(''); - const [storage, setStorage] = useState(ByokKeyStorage.server); + const [profileEnabled, setProfileEnabled] = useState(true); + const [storage, setStorage] = useState(ByokStorage.server); const [apiKey, setApiKey] = useState(''); + const [customEndpoint, setCustomEndpoint] = useState(false); const [endpoint, setEndpoint] = useState(''); - const [testResult, setTestResult] = useState(null); - const [testing, setTesting] = useState(false); - const canTestStoredConfig = - storage === ByokKeyStorage.server && - editingKey?.storage === ByokKeyStorage.server && - editingKey.provider === provider; - const canTest = !!apiKey || canTestStoredConfig; + const [models, setModels] = useState([]); + const [testStatus, setTestStatus] = useState<'passed' | 'failed' | null>( + null + ); + const [includeImageProbe, setIncludeImageProbe] = useState(false); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + const localStorageUnavailable = !localStorageSupported || !canAddLocalKey; + const localStorageDisabled = !!editingKey || localStorageUnavailable; + const showCustomEndpoint = shouldShowEndpoint( + isSelfHosted, + settings.customEndpointSupported + ); + const endpointHint = endpointHintKey( settings.customEndpointSupported, settings.privateEndpointSupported ); + const providerCatalog = useMemo( + () => catalogModels(settings, provider), + [provider, settings] + ); useEffect(() => { - if (!open) { - return; - } - setProvider(editingKey?.provider ?? ByokProvider.openai); - setName(editingKey?.name ?? ''); + if (!open) return; + const nextProvider = editingKey?.provider ?? ByokProvider.openai; + setProvider(nextProvider); + setName(editingKey?.name ?? providerLabels[nextProvider]); setDescription(editingKey?.description ?? ''); + setProfileEnabled(editingKey?.enabled ?? true); setStorage( editingKey?.storage ?? - (canAddServerKey ? ByokKeyStorage.server : ByokKeyStorage.local) + (canAddServerKey ? ByokStorage.server : ByokStorage.local) ); setApiKey(''); - setEndpoint(editingKey?.endpoint ?? ''); - setTestResult(null); - }, [canAddServerKey, editingKey, open]); + setEndpoint(editingKey?.definition.endpoint.url ?? ''); + setCustomEndpoint(editingKey?.definition.endpoint.kind === 'custom'); + setModels( + editingKey?.definition.models ?? defaultModels(settings, nextProvider) + ); + setTestStatus(null); + setIncludeImageProbe(false); + }, [canAddServerKey, editingKey, open, settings]); - const testKey = useCallback(async () => { - if (!gql) { - return; - } - setTesting(true); - try { - const result = await gql({ - query: testByokMutation, - variables: { - input: { - workspaceId, - provider, - storage, - apiKey: apiKey || null, - endpoint: endpoint || null, - configId: canTestStoredConfig ? editingKey.id : null, - }, + const definition = useMemo( + () => ({ + version: editingKey?.definition.version ?? 1, + endpoint: customEndpoint + ? { kind: 'custom', url: endpoint } + : { kind: 'provider_default', url: null }, + models, + }), + [customEndpoint, editingKey?.definition.version, endpoint, models] + ); + + const invalidateTest = () => setTestStatus(null); + const runProbe = useCallback(async () => { + if (!gql) return false; + const canReuseServerCredential = + editingKey?.storage === ByokStorage.server && !apiKey; + const checks = probeChecks(models, includeImageProbe); + const result = await gql({ + query: probeWorkspaceByokDraftMutation, + variables: { + input: { + workspaceId, + provider, + credential: apiKey || null, + profileId: canReuseServerCredential ? editingKey.id : null, + expectedRevision: canReuseServerCredential + ? (editingKey.revision ?? null) + : null, + definition, + checks, }, - }); - const nextResult = result.testWorkspaceByokConfig as - | ByokTestResult - | undefined; - setTestResult(nextResult ?? null); - if (nextResult && !nextResult.ok) { - notify.error({ - title: byokT(t, 'notify.test-failed.title'), - message: nextResult.message, - }); - } - } finally { - setTesting(false); - } + }, + }); + const probe = result.probeWorkspaceByokDraft; + const verifiedChecks = new Set( + probe.models.flatMap(model => + model.checks + .filter(check => check.status.kind === 'verified') + .map(check => `${model.modelId}\0${check.operation}`) + ) + ); + const passed = + checks.length > 0 && + probe.connection.kind === 'verified' && + checks.every(check => + verifiedChecks.has(`${check.modelId}\0${check.operation}`) + ); + setTestStatus(passed ? 'passed' : 'failed'); + return passed; }, [ apiKey, - canTestStoredConfig, + definition, editingKey, - endpoint, gql, + includeImageProbe, + models, provider, - storage, - t, workspaceId, ]); - const save = useCallback(async () => { - if (!testResult?.ok || !gql) { - return; - } - if (storage === ByokKeyStorage.local) { + const persist = useCallback(async () => { + if (!gql) return; + if (storage === ByokStorage.local) { const saved = await upsertLocalKey(workspaceId, { id: - editingKey?.storage === ByokKeyStorage.local + editingKey?.storage === ByokStorage.local ? editingKey.id : crypto.randomUUID(), provider, name, description, - apiKey, - endpoint: endpoint || null, + credential: apiKey, + definition, sortOrder: - editingKey?.storage === ByokKeyStorage.local + editingKey?.storage === ByokStorage.local ? editingKey.sortOrder : localKeys.length, - enabled: true, + enabled: profileEnabled, }); if (!saved) { notify.error({ @@ -163,189 +198,359 @@ export const AddKeyModal = ({ return; } setLocalKeys(await readLocalKeys(workspaceId)); - } else { + } else if (editingKey?.storage === ByokStorage.server) { + if (editingKey.revision === undefined) { + notify.error({ + title: byokT(t, 'notify.reload-required.title'), + message: byokT(t, 'notify.reload-required.message'), + }); + return; + } await gql({ - query: upsertByokMutation, + query: replaceWorkspaceByokProfileMutation, + variables: { + input: { + workspaceId, + profileId: editingKey.id, + expectedRevision: editingKey.revision, + name, + description: description || null, + credential: apiKey || null, + definition, + enabled: profileEnabled, + }, + }, + }); + await onSaved(); + } else { + await gql({ + query: createWorkspaceByokProfileMutation, variables: { input: { workspaceId, - id: - editingKey?.storage === ByokKeyStorage.server - ? editingKey.id - : null, provider, name, - description, - storage, - apiKey: apiKey || null, - endpoint: endpoint || null, - enabled: true, + description: description || null, + credential: apiKey, + definition, + enabled: profileEnabled, }, }, }); await onSaved(); } onOpenChange(false); - setApiKey(''); - setTestResult(null); }, [ apiKey, + definition, description, editingKey, - endpoint, gql, - localKeys, + localKeys.length, name, onOpenChange, onSaved, provider, + profileEnabled, setLocalKeys, storage, t, - testResult?.ok, workspaceId, ]); + const connect = useCallback(async () => { + if (busyRef.current) return; + busyRef.current = true; + setBusy(true); + try { + const passed = testStatus === 'passed' || (await runProbe()); + if (!passed) { + notify.error({ + title: byokT(t, 'notify.test-failed.title'), + message: byokT(t, 'notify.operation-failed.message'), + }); + return; + } + await persist(); + } finally { + busyRef.current = false; + setBusy(false); + } + }, [persist, runProbe, t, testStatus]); + + const testConnection = useCallback(async () => { + if (busyRef.current) return; + busyRef.current = true; + setBusy(true); + try { + await runProbe(); + } finally { + busyRef.current = false; + setBusy(false); + } + }, [runProbe]); + + const hasCredential = !!apiKey || editingKey?.storage === ByokStorage.server; + const valid = + !!name.trim() && + hasCredential && + models.length > 0 && + models.every(model => model.modelId.trim() && model.capabilities.length) && + new Set(models.map(model => model.modelId.trim())).size === models.length && + (!customEndpoint || !!endpoint.trim()); + return (
- - - - - {' '} - {shouldShowEndpoint(isSelfHosted, settings.customEndpointSupported) ? ( -
+ +
+
+
+
+ {byokT(t, 'section.models')} +
+
+ {byokT(t, 'models.description.selected')} +
+
+
+ { + setModels(models); + invalidateTest(); + }} + /> +
+ +
+ {byokT(t, 'section.advanced')} +
+ + + +
+
+ + {models.some(model => modelUseCases(model).includes('image')) ? ( + ) : null} +
- {testResult?.ok - ? byokT(t, 'status.key-verified') - : testResult - ? byokT(t, 'status.key-test-failed') + {testStatus === 'passed' + ? byokT(t, 'probe.verified') + : testStatus === 'failed' + ? byokT(t, 'probe.failed') : ''}
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/coverage.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/coverage.tsx index d0cd09ad6a..3c3fba5b73 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/coverage.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/coverage.tsx @@ -9,8 +9,8 @@ import { import type { ReactNode } from 'react'; import * as styles from './index.css'; -import { byokT, capabilityRows, warningDescription } from './metadata'; -import type { ByokKey, ByokSettings } from './types'; +import { byokT, capabilityRows } from './metadata'; +import type { ByokKey } from './types'; function coverageIcon( icon: (typeof capabilityRows)[number]['icon'] @@ -47,13 +47,7 @@ function isRowCovered(row: (typeof capabilityRows)[number], keys: ByokKey[]) { }); } -export const CoveragePanel = ({ - keys, - settings, -}: { - keys: ByokKey[]; - settings: ByokSettings; -}) => { +export const CoveragePanel = ({ keys }: { keys: ByokKey[] }) => { const t = useI18n(); return ( @@ -63,9 +57,6 @@ export const CoveragePanel = ({
{capabilityRows.map(row => { - const warning = settings.warnings.find( - w => w.featureKind === row.featureKind - ); const covered = isRowCovered(row, keys); return (
{byokT(t, row.titleKey)}
- {warningDescription(t, warning) ?? byokT(t, row.fallbackKey)} + {byokT(t, row.fallbackKey)}
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.css.ts index 68cf41b0ef..c2c2dec4ae 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.css.ts @@ -86,7 +86,7 @@ export const capabilityIcon = style({ export const capabilityIconActive = style({ color: cssVarV2('button/primary'), - background: '#f0f7ff', + background: cssVarV2('chip/label/blue'), }); export const capabilityIconSvg = style({ @@ -176,14 +176,16 @@ export const locked = style({ export const form = style({ display: 'flex', flexDirection: 'column', - gap: 12, + gap: 16, + maxHeight: 'min(720px, calc(100vh - 180px))', + overflowY: 'auto', + paddingRight: 2, }); export const field = style({ display: 'flex', flexDirection: 'column', gap: 4, - height: '3em', }); export const endpointField = style([field, { height: 'auto' }]); @@ -201,6 +203,8 @@ export const fieldHint = style({ export const input = style({ height: 32, + minHeight: 32, + maxHeight: 32, width: '100%', boxSizing: 'border-box', borderRadius: 8, @@ -228,6 +232,319 @@ export const modalActions = style({ justifyContent: 'flex-end', gap: 8, marginTop: 8, + position: 'sticky', + bottom: 0, + paddingTop: 12, + background: cssVarV2('layer/background/primary'), +}); + +export const formSection = style({ + display: 'flex', + flexDirection: 'column', + gap: 12, + padding: 14, + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + borderRadius: 10, +}); + +export const sectionHeading = style({ + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 12, +}); + +export const sectionTitle = style({ + fontSize: cssVar('fontSm'), + fontWeight: 600, + color: cssVarV2('text/primary'), +}); + +export const storageOptions = style({ + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: 8, +}); + +export const storageOption = style({ + position: 'relative', + display: 'flex', + alignItems: 'flex-start', + minHeight: 76, + boxSizing: 'border-box', + padding: 12, + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + borderRadius: 8, + color: cssVarV2('text/primary'), + fontSize: cssVar('fontSm'), + cursor: 'pointer', + selectors: { + '&:has(input:checked)': { + borderColor: cssVarV2('button/primary'), + background: cssVarV2('layer/background/secondary'), + }, + '&:has(input:focus-visible)': { + boxShadow: '0px 0px 0px 2px rgba(30, 150, 235, 0.30)', + }, + '&[data-disabled="true"]': { + cursor: 'not-allowed', + color: cssVarV2('text/disable'), + background: cssVarV2('layer/background/secondary'), + }, + }, +}); + +export const storageRadio = style({ + position: 'absolute', + width: 1, + height: 1, + margin: 0, + opacity: 0, + pointerEvents: 'none', +}); + +export const storageCopy = style({ + display: 'flex', + minWidth: 0, + flexDirection: 'column', + gap: 2, + lineHeight: '20px', +}); + +export const storageDescription = style({ + color: cssVarV2('text/secondary'), + fontSize: cssVar('fontXs'), + selectors: { + [`${storageOption}[data-disabled="true"] &`]: { + color: cssVarV2('text/disable'), + }, + }, +}); + +export const checkboxRow = style({ + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: cssVar('fontXs'), + color: cssVarV2('text/primary'), +}); + +export const modelToolbar = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, +}); + +export const selectedModels = style({ + display: 'flex', + flexDirection: 'column', + gap: 6, + margin: 0, + padding: 0, + listStyle: 'none', +}); + +export const selectedModel = style({ + display: 'grid', + gridTemplateColumns: '20px minmax(0, 1fr) auto auto auto', + alignItems: 'center', + gap: 10, + minHeight: 72, + padding: '10px 12px', + borderRadius: 8, + background: cssVarV2('layer/background/secondary'), + color: cssVarV2('text/primary'), + fontSize: cssVar('fontSm'), +}); + +export const selectedModelDisabled = style({ + opacity: 0.58, +}); + +export const modelDragHandle = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: cssVarV2('text/secondary'), + cursor: 'grab', +}); + +export const modelCopy = style({ + display: 'flex', + minWidth: 0, + flexDirection: 'column', + gap: 2, +}); + +export const modelStatus = style({ + color: cssVarV2('text/secondary'), + fontSize: cssVar('fontXs'), + whiteSpace: 'nowrap', +}); + +export const recommended = style({ + padding: '2px 6px', + borderRadius: 999, + color: cssVarV2('button/primary'), + background: cssVarV2('chip/label/blue'), + fontSize: 11, + fontWeight: 400, + lineHeight: '16px', +}); + +export const modelEmpty = style({ + padding: '24px 16px', + borderRadius: 8, + textAlign: 'center', + color: cssVarV2('text/secondary'), + background: cssVarV2('layer/background/secondary'), + fontSize: cssVar('fontXs'), +}); + +export const modelModalBody = style({ + display: 'flex', + flexDirection: 'column', + gap: 12, + maxHeight: 'min(440px, calc(100dvh - 220px))', + overflowY: 'auto', +}); + +export const modelModalDescription = style({ + margin: '0 0 12px', + color: cssVarV2('text/secondary'), + fontSize: cssVar('fontSm'), + lineHeight: '20px', +}); + +export const modelSearch = style({ + flexShrink: 0, +}); + +export const modelFieldLabel = style({ + color: cssVarV2('text/secondary'), + fontSize: cssVar('fontSm'), + fontWeight: 500, + lineHeight: '20px', +}); + +export const catalogChoices = style({ + display: 'flex', + flexDirection: 'column', + gap: 4, + paddingBottom: 8, +}); + +export const catalogChoice = style({ + display: 'grid', + gridTemplateColumns: '16px minmax(0, 1fr)', + alignItems: 'center', + columnGap: 10, + minHeight: 56, + boxSizing: 'border-box', + padding: '8px 10px', + border: '1px solid transparent', + borderRadius: 8, + background: cssVarV2('layer/background/secondary'), + color: cssVarV2('text/primary'), + fontSize: cssVar('fontSm'), + cursor: 'pointer', + selectors: { + '&[data-selected="true"]': { + borderColor: cssVarV2('button/primary'), + background: cssVarV2('chip/label/blue'), + }, + }, +}); + +export const modelCheckbox = style({ + flex: '0 0 auto', + fontSize: 16, +}); + +export const catalogModelCopy = style({ + display: 'flex', + minWidth: 0, + flexDirection: 'column', + gap: 2, +}); + +export const catalogModelTitle = style({ + display: 'flex', + minWidth: 0, + alignItems: 'center', + gap: 6, + fontSize: cssVar('fontSm'), + lineHeight: '20px', +}); + +export const catalogModelMeta = style({ + overflow: 'hidden', + color: cssVarV2('text/secondary'), + fontSize: cssVar('fontXs'), + lineHeight: '16px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const modelCapabilities = style({ + minWidth: 0, + margin: 0, + padding: 0, + border: 0, +}); + +export const useCaseGrid = style({ + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + columnGap: 16, + rowGap: 4, + marginTop: 8, + '@media': { + '(max-width: 600px)': { + gridTemplateColumns: '1fr', + }, + }, +}); + +export const modelUseCase = style({ + width: '100%', + minHeight: 28, + gap: 8, + color: cssVarV2('text/primary'), + fontSize: 16, + lineHeight: '20px', +}); + +export const modelUseCaseLabel = style({ + fontSize: cssVar('fontSm'), +}); + +export const modelModalActions = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: 8, + marginTop: 12, + paddingTop: 12, + borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`, +}); + +export const advanced = style({ + color: cssVarV2('text/secondary'), + fontSize: cssVar('fontSm'), +}); + +export const advancedFields = style({ + display: 'flex', + flexDirection: 'column', + gap: 10, + marginTop: 10, +}); + +export const inputStack = style({ + display: 'flex', + flexDirection: 'column', + gap: 4, }); export const testStatus = style({ diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.spec.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.spec.tsx index b27dfdd02f..eef33f25d6 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.spec.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.spec.tsx @@ -8,29 +8,15 @@ import { render, screen, waitFor, + within, } from '@testing-library/react'; -import type * as Infra from '@toeverything/infra'; -import type { ButtonHTMLAttributes, ReactNode } from 'react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -const gqlMock = vi.hoisted(() => vi.fn()); -const workspaceState = vi.hoisted(() => ({ - id: 'workspace-1', -})); -const electronApiState = vi.hoisted(() => ({ - apis: undefined as - | { - byokStorage?: { - isSupported: () => Promise; - listWorkspaceKeys: (workspaceId: string) => Promise; - }; - } - | undefined, -})); -const WorkspaceServerServiceToken = vi.hoisted( - () => class WorkspaceServerService {} -); -const WorkspaceServiceToken = vi.hoisted(() => class WorkspaceService {}); +import type { + ButtonHTMLAttributes, + ChangeEvent, + InputHTMLAttributes, + ReactNode, +} from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; const ByokProvider = vi.hoisted(() => ({ openai: 'openai', @@ -38,35 +24,9 @@ const ByokProvider = vi.hoisted(() => ({ gemini: 'gemini', fal: 'fal', })); -const ByokKeyStorage = vi.hoisted(() => ({ - server: 'server', - local: 'local', -})); -const ByokKeyTestStatus = vi.hoisted(() => ({ - untested: 'untested', - passed: 'passed', - failed: 'failed', -})); -const ServerDeploymentType = vi.hoisted(() => ({ - Affine: 'Affine', - Selfhosted: 'Selfhosted', -})); - -const workspaceByokSettingsQuery = vi.hoisted(() => - Symbol('workspaceByokSettingsQuery') -); -const testWorkspaceByokConfigMutation = vi.hoisted(() => - Symbol('testWorkspaceByokConfigMutation') -); -const upsertWorkspaceByokConfigMutation = vi.hoisted(() => - Symbol('upsertWorkspaceByokConfigMutation') -); -const clearWorkspaceByokConfigsMutation = vi.hoisted(() => - Symbol('clearWorkspaceByokConfigsMutation') -); -const deleteWorkspaceByokConfigMutation = vi.hoisted(() => - Symbol('deleteWorkspaceByokConfigMutation') -); +const createMutation = vi.hoisted(() => Symbol('create')); +const probeMutation = vi.hoisted(() => Symbol('probe')); +const replaceMutation = vi.hoisted(() => Symbol('replace')); vi.mock('@affine/component', () => ({ Button: ({ @@ -75,632 +35,467 @@ vi.mock('@affine/component', () => ({ }: ButtonHTMLAttributes & { children: ReactNode }) => ( ), - DragHandle: () => drag-handle, + Input: ({ + onChange, + size: _size, + ...props + }: Omit, 'onChange' | 'size'> & { + onChange?: (value: string) => void; + size?: string; + }) => ( + onChange?.(event.currentTarget.value)} + /> + ), + Checkbox: ({ + checked, + onChange, + label, + name, + 'aria-label': ariaLabel, + }: { + checked: boolean; + onChange?: (event: ChangeEvent, checked: boolean) => void; + label?: string; + name?: string; + 'aria-label'?: string; + }) => ( + + onChange?.(event, event.currentTarget.checked)} + /> + {label} + + ), + Modal: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + Switch: ({ + checked, + onChange, + ...props + }: { + checked: boolean; + onChange: (checked: boolean) => void; + 'aria-label'?: string; + }) => ( + onChange(event.currentTarget.checked)} + /> + ), + DragHandle: () => drag, IconButton: ({ title, onClick }: { title: string; onClick?: () => void }) => ( ), - Modal: ({ - open, - title, + Menu: ({ children, items }: { children: ReactNode; items: ReactNode }) => ( +
+ {children} + {items} +
+ ), + MenuItem: ({ children, + disabled, + onSelect, }: { - open: boolean; - title: string; children: ReactNode; - }) => - open ? ( -
- {children} -
- ) : null, - notify: { - error: vi.fn(), - }, -})); - -vi.mock('@affine/component/setting-components', () => ({ - SettingHeader: ({ - title, - subtitle, - }: { - title: string; - subtitle?: string; + disabled?: boolean; + onSelect?: () => void; }) => ( -
-

{title}

- {subtitle ?

{subtitle}

: null} -
+ ), - SettingWrapper: ({ children }: { children: ReactNode }) => ( -
{children}
- ), -})); - -vi.mock('@affine/core/modules/cloud', () => ({ - WorkspaceServerService: WorkspaceServerServiceToken, -})); - -vi.mock('@affine/core/modules/workspace', () => ({ - WorkspaceService: WorkspaceServiceToken, -})); - -vi.mock('@affine/electron-api', () => ({ - get apis() { - return electronApiState.apis; - }, + notify: { error: vi.fn() }, })); vi.mock('@affine/graphql', () => ({ - ByokKeyStorage, - ByokKeyTestStatus, ByokProvider, - ServerDeploymentType, - clearWorkspaceByokConfigsMutation, - deleteWorkspaceByokConfigMutation, - testWorkspaceByokConfigMutation, - upsertWorkspaceByokConfigMutation, - workspaceByokSettingsQuery, + createWorkspaceByokProfileMutation: createMutation, + probeWorkspaceByokDraftMutation: probeMutation, + replaceWorkspaceByokProfileMutation: replaceMutation, })); -vi.mock('@affine/i18n', () => { - const messages: Record = { - 'com.affine.settings.workspace.byok.action.add-key': 'Add key', - 'com.affine.settings.workspace.byok.action.edit': 'Edit', - 'com.affine.settings.workspace.byok.action.delete': 'Delete', - 'com.affine.settings.workspace.byok.action.test-key': 'Test key', - 'com.affine.settings.workspace.byok.action.save-key': 'Save key', - 'com.affine.settings.workspace.byok.action.cancel': 'Cancel', - 'com.affine.settings.workspace.byok.action.clear-all': - 'Clear all BYOK keys', - 'com.affine.settings.workspace.byok.field.api-key': 'API key', - 'com.affine.settings.workspace.byok.field.storage': 'Key storage', - 'com.affine.settings.workspace.byok.placeholder.key-name': 'Primary', - 'com.affine.settings.workspace.byok.status.key-verified': 'Key verified', - 'com.affine.settings.workspace.byok.status.disabled-after-failure': - 'Disabled after failure', - 'com.affine.settings.workspace.byok.storage.local': 'Local', - 'com.affine.settings.workspace.byok.storage.server': 'Server', - 'com.affine.settings.workspace.byok.storage.local-this-device': - 'Local (this device)', - 'com.affine.settings.workspace.byok.storage.local-desktop-only': - 'Local (Desktop only)', - 'com.affine.settings.workspace.byok.usage.tokens': '{{count}} tokens', - 'com.affine.settings.workspace.byok.notify.operation-failed.message': - 'Please try again.', - 'com.affine.settings.workspace.byok.notify.test-failed.title': - 'Key test failed', - 'com.affine.settings.workspace.byok.notify.load-failed.title': - 'BYOK settings not loaded', - 'com.affine.settings.workspace.byok.notify.save-failed.title': - 'BYOK key not saved', - 'com.affine.settings.workspace.byok.notify.delete-failed.title': - 'BYOK key not deleted', - 'com.affine.settings.workspace.byok.notify.reorder-failed.title': - 'BYOK keys not reordered', - 'com.affine.settings.workspace.byok.notify.clear-failed.title': - 'BYOK keys not cleared', - }; - const translate = (key: string, options?: Record) => { - let message = messages[key] ?? key; - for (const [name, value] of Object.entries(options ?? {})) { - message = message.replaceAll(`{{${name}}}`, String(value)); - } - return message; - }; - const t = new Proxy( - { - t: translate, - }, - { - get(target, key: string) { - if (key in target) { - return target[key as keyof typeof target]; - } - return (options?: Record) => translate(key, options); - }, - } - ); - - return { - useI18n: () => t, - }; -}); - -vi.mock('@blocksuite/icons/rc', () => ({ - ChatWithAiIcon: () => chat-ai, - DeleteIcon: () => delete, - EditIcon: () => edit, - ImageIcon: () => image, - PenIcon: () => pen, - TocIcon: () => toc, - TranscriptWithAiIcon: () => transcript, +vi.mock('@affine/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key.split('.').at(-1) ?? key }), })); -vi.mock('@toeverything/infra', async importOriginal => { - const actual = await importOriginal(); +import { AddKeyModal } from './add-key-modal'; +import { endpointHintKey } from './metadata'; - return { - ...actual, - useService: (token: unknown) => { - if (token === WorkspaceServerServiceToken) { - return { - server: { - ['config$']: { value: { type: ServerDeploymentType.Affine } }, - gql: gqlMock, - }, - }; - } - if (token === WorkspaceServiceToken) { - return { - workspace: workspaceState, - }; - } - return {}; - }, - }; -}); +const textCapability = { + input: ['text'], + output: ['text'], + features: [], + attachmentKinds: [], + attachmentSources: [], +}; -import { WorkspaceByokSetting } from '.'; -import { logByokError } from './errors'; -import { UsagePanel } from './usage'; - -function settings(overrides: Record = {}) { +function settings(customEndpointSupported = true) { return { workspaceId: 'workspace-1', entitled: true, serverEntitled: true, localEntitled: false, - entitlementRequired: ['Pro', 'Team', 'Believer'], - allowedProviders: ['openai', 'anthropic', 'gemini', 'fal'], + allowedProviders: Object.values(ByokProvider), + customEndpointSupported, + privateEndpointSupported: false, localStorageSupported: false, - customEndpointSupported: false, - hasAiPlan: true, keys: [], - warnings: [], - ...overrides, - }; -} - -function byokKey(overrides: Record = {}) { - return { - id: 'server-key', - provider: ByokProvider.openai, - name: 'Primary', - description: 'Workspace fallback key', - storage: ByokKeyStorage.server, - configured: true, - enabled: true, - endpoint: null, - endpointEditable: false, - sortOrder: 0, - capabilities: ['Text', 'Image input', 'Actions', 'Image generate'], - testStatus: ByokKeyTestStatus.passed, - disabledReason: null, - lastTestedAt: null, - lastTestError: null, - lastUsedAt: null, - lastErrorAt: null, - lastError: null, - ...overrides, - }; -} - -function settingsResponse(overrides: Record = {}) { - return { - workspace: { - byokSettings: settings(overrides), - byokUsage: [], + catalog: { + version: 'catalog-1', + providers: [ + { + provider: ByokProvider.openai, + models: [ + { + modelId: 'model-a', + displayName: 'Model A', + recommended: true, + capabilities: [textCapability], + }, + { + modelId: 'model-b', + displayName: 'Model B', + recommended: false, + capabilities: [textCapability], + }, + ], + }, + ], }, }; } -describe('WorkspaceByokSetting', () => { +describe('BYOK settings behavior', () => { afterEach(() => { cleanup(); - vi.unstubAllGlobals(); + vi.clearAllMocks(); }); - beforeEach(() => { - gqlMock.mockReset(); - gqlMock.mockImplementation(async ({ query }) => { - if (query === workspaceByokSettingsQuery) { - return settingsResponse(); - } - throw new Error('Unexpected GraphQL operation'); - }); - vi.stubGlobal('BUILD_CONFIG', { isElectron: false }); - electronApiState.apis = undefined; - }); + test.each([ + [false, false, 'endpoint.custom-disabled'], + [true, false, 'endpoint.private-disabled'], + [true, true, null], + ] as const)( + 'maps endpoint policy custom=%s private=%s', + (customEndpointSupported, privateEndpointSupported, expected) => { + expect( + endpointHintKey(customEndpointSupported, privateEndpointSupported) + ).toBe(expected); + } + ); - test('renders locked state without key management controls', async () => { - gqlMock.mockImplementation(async ({ query }) => { - if (query === workspaceByokSettingsQuery) { - return settingsResponse({ - entitled: false, - serverEntitled: false, - localEntitled: false, - }); - } - throw new Error('Unexpected GraphQL operation'); - }); - - render(); - - await screen.findByTestId('workspace-byok-locked'); - expect(screen.queryByText('Add key')).toBeNull(); - expect(screen.queryByTestId('workspace-byok-empty')).toBeNull(); - }); - - test('renders empty state and keeps save disabled until key test passes', async () => { - gqlMock.mockImplementation(async ({ query }) => { - if (query === workspaceByokSettingsQuery) { - return settingsResponse(); - } - if (query === testWorkspaceByokConfigMutation) { - return { - testWorkspaceByokConfig: { - ok: true, - status: 'passed', - message: null, - }, - }; - } - if (query === upsertWorkspaceByokConfigMutation) { - return { upsertWorkspaceByokConfig: { id: 'server-key' } }; - } - throw new Error('Unexpected GraphQL operation'); - }); - - render(); - - await screen.findByTestId('workspace-byok-empty'); - fireEvent.click(screen.getAllByText('Add key')[0]); - expect(screen.getByText('Save key').disabled).toBe(true); - - fireEvent.change(screen.getByPlaceholderText('Primary'), { - target: { value: 'Primary' }, - }); - fireEvent.change(screen.getByLabelText('API key'), { - target: { value: 'sk-test' }, - }); - fireEvent.click(screen.getByText('Test key')); - - await screen.findByText('Key verified'); - expect(screen.getByText('Save key').disabled).toBe( - false - ); - fireEvent.click(screen.getByText('Save key')); - - await waitFor(() => { - expect(gqlMock).toHaveBeenCalledWith( - expect.objectContaining({ - query: upsertWorkspaceByokConfigMutation, - }) - ); - }); - }); - - test('keeps local storage disabled on web even for local-entitled users', async () => { - gqlMock.mockImplementation(async ({ query }) => { - if (query === workspaceByokSettingsQuery) { - return settingsResponse({ - localEntitled: true, - localStorageSupported: true, - }); - } - throw new Error('Unexpected GraphQL operation'); - }); - - render(); - - await screen.findByTestId('workspace-byok-empty'); - fireEvent.click(screen.getAllByText('Add key')[0]); - - const storageSelect = - screen.getByLabelText('Key storage'); - const localOption = Array.from(storageSelect.options).find( - option => option.value === ByokKeyStorage.local - ); - expect(localOption?.disabled).toBe(true); - }); - - test('reorders server keys within their storage bucket', async () => { - gqlMock.mockImplementation(async ({ query }) => { - if (query === workspaceByokSettingsQuery) { - return settingsResponse({ - keys: [ - byokKey({ id: 'server-1', name: 'First', sortOrder: 0 }), - byokKey({ id: 'server-2', name: 'Second', sortOrder: 1 }), - ], - }); - } - return {}; - }); - - render(); - - const firstRow = (await screen.findByText('OpenAI / First')).closest( - '[draggable="true"]' - ); - const secondRow = screen - .getByText('OpenAI / Second') - .closest('[draggable="true"]'); - - expect(firstRow).not.toBeNull(); - expect(secondRow).not.toBeNull(); - fireEvent.dragStart(firstRow as Element); - fireEvent.dragOver(secondRow as Element); - fireEvent.drop(secondRow as Element); - - await waitFor(() => { - expect(gqlMock).toHaveBeenCalledWith( - expect.objectContaining({ - variables: expect.objectContaining({ - input: expect.objectContaining({ - workspaceId: 'workspace-1', - storage: ByokKeyStorage.server, - ids: ['server-2', 'server-1'], - }), - }), - }) - ); - }); - }); - - test('marks coverage rows by configured provider support', async () => { - let keys = [ - byokKey({ provider: ByokProvider.openai }), - byokKey({ - id: 'disabled-gemini', - provider: ByokProvider.gemini, - enabled: false, - capabilities: [ - 'Text', - 'Image input', - 'Actions', - 'Image generate', - 'Transcript', - 'Indexing', - ], - }), - byokKey({ - id: 'local-gemini', - provider: ByokProvider.gemini, - storage: ByokKeyStorage.local, - capabilities: ['Text', 'Image input', 'Actions', 'Image generate'], - }), - ]; - - gqlMock.mockImplementation(async ({ query }) => { - if (query === workspaceByokSettingsQuery) { - return settingsResponse({ - keys, - }); - } - throw new Error('Unexpected GraphQL operation'); - }); - - render(); + test('shows a disabled custom endpoint control with its self-hosted policy hint', () => { + const props = { + workspaceId: 'workspace-1', + settings: settings(false) as never, + editingKey: null, + open: true, + onOpenChange: vi.fn(), + onSaved: vi.fn(), + localKeys: [], + setLocalKeys: vi.fn(), + localStorageSupported: false, + canAddServerKey: true, + canAddLocalKey: false, + gql: vi.fn() as never, + }; + const { rerender } = render(); expect( - (await screen.findByTestId('workspace-byok-coverage-chat')).dataset - .covered - ).toBe('true'); - expect( - screen.getByTestId('workspace-byok-coverage-action').dataset.covered - ).toBe('true'); - expect( - screen.getByTestId('workspace-byok-coverage-image').dataset.covered - ).toBe('true'); - expect( - screen.getByTestId('workspace-byok-coverage-transcript').dataset.covered - ).toBe('false'); - expect( - screen.getByTestId('workspace-byok-coverage-workspace_indexing').dataset - .covered - ).toBe('false'); - expect(screen.getAllByTestId(/^workspace-byok-coverage-/)).toHaveLength(5); + ( + screen.getByRole('checkbox', { + name: 'use-custom', + }) as HTMLInputElement + ).disabled + ).toBe(true); + expect(screen.getByText('custom-disabled')).toBeTruthy(); - cleanup(); - keys = [ - byokKey({ - provider: ByokProvider.gemini, - capabilities: [ - 'Text', - 'Image input', - 'Actions', - 'Image generate', - 'Transcript', - 'Indexing', - ], - }), - ]; - render(); - - expect( - (await screen.findByTestId('workspace-byok-coverage-transcript')).dataset - .covered - ).toBe('true'); - expect( - screen.getByTestId('workspace-byok-coverage-workspace_indexing').dataset - .covered - ).toBe('true'); + rerender(); + expect(screen.queryByRole('checkbox', { name: 'use-custom' })).toBeNull(); + expect(screen.queryByText('custom-disabled')).toBeNull(); }); - test('restores a failed server row after key test passes', async () => { - gqlMock.mockImplementation(async ({ query }) => { - if (query === workspaceByokSettingsQuery) { - return settingsResponse({ - keys: [ - byokKey({ - enabled: false, - testStatus: ByokKeyTestStatus.failed, - disabledReason: 'recent_failure', - lastErrorAt: '2026-05-01T00:00:00.000Z', - lastError: 'Provider rejected the API key.', - }), - ], - }); - } - if (query === testWorkspaceByokConfigMutation) { - return { - testWorkspaceByokConfig: { - ok: true, - status: 'passed', - message: null, - }, - }; - } - if (query === upsertWorkspaceByokConfigMutation) { - return { - upsertWorkspaceByokConfig: { - id: 'server-key', - }, - }; - } - throw new Error('Unexpected GraphQL operation'); - }); - - render(); - - await screen.findByText('Disabled after failure'); - fireEvent.click(screen.getByText('Edit')); - fireEvent.change(screen.getByLabelText('API key'), { - target: { value: 'sk-test' }, - }); - fireEvent.click(screen.getByText('Test key')); - - await screen.findByText('Key verified'); - fireEvent.click(screen.getByText('Save key')); - - await waitFor(() => { - expect(gqlMock).toHaveBeenCalledWith( - expect.objectContaining({ - query: upsertWorkspaceByokConfigMutation, - variables: expect.objectContaining({ - input: expect.objectContaining({ - id: 'server-key', - enabled: true, - }), - }), - }) - ); - }); - }); - - test('tests a saved server key without resending plaintext', async () => { - gqlMock.mockImplementation(async ({ query }) => { - if (query === workspaceByokSettingsQuery) { - return settingsResponse({ - keys: [byokKey()], - }); - } - if (query === testWorkspaceByokConfigMutation) { - return { - testWorkspaceByokConfig: { - ok: true, - status: 'passed', - message: null, - }, - }; - } - if (query === upsertWorkspaceByokConfigMutation) { - return { - upsertWorkspaceByokConfig: { - id: 'server-key', - }, - }; - } - throw new Error('Unexpected GraphQL operation'); - }); - - render(); - - await screen.findByText('OpenAI / Primary'); - fireEvent.click(screen.getByText('Edit')); - expect(screen.getByText('Test key').disabled).toBe( - false - ); - fireEvent.click(screen.getByText('Test key')); - - await waitFor(() => { - expect(gqlMock).toHaveBeenCalledWith( - expect.objectContaining({ - query: testWorkspaceByokConfigMutation, - variables: expect.objectContaining({ - input: expect.objectContaining({ - apiKey: null, - configId: 'server-key', - }), - }), - }) - ); - }); - - await screen.findByText('Key verified'); - fireEvent.click(screen.getByText('Save key')); - - await waitFor(() => { - expect(gqlMock).toHaveBeenCalledWith( - expect.objectContaining({ - query: upsertWorkspaceByokConfigMutation, - variables: expect.objectContaining({ - input: expect.objectContaining({ - apiKey: null, - id: 'server-key', - }), - }), - }) - ); - }); - }); -}); - -describe('UsagePanel', () => { - afterEach(() => { - cleanup(); - }); - - test('aggregates usage rows by date before rendering bars', () => { - const today = new Date().toISOString(); + test('shows selected models separately and adds catalog or custom models', () => { render( - {}} + ); - expect(screen.getByTitle('8 tokens')).not.toBeNull(); - }); -}); + expect(screen.getByText('Model A')).toBeTruthy(); + expect(screen.queryByText('Model B')).toBeNull(); + expect( + (screen.getByRole('radio', { name: /local/i }) as HTMLInputElement) + .disabled + ).toBe(true); + expect(screen.getByText('desktop-only')).toBeTruthy(); -describe('logByokError', () => { - test('logs safe metadata without raw error message', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const error = Object.assign( - new Error('authorization: Bearer token=a+b%2F=='), - { - code: 'BAD_REQUEST', - status: 400, - type: 'bad_request', - } + fireEvent.click(screen.getByRole('button', { name: 'add-model' })); + fireEvent.click(screen.getByRole('checkbox', { name: /Model B/ })); + fireEvent.click( + screen.getByRole('button', { name: 'add-selected-models' }) + ); + expect(screen.getByText('Model B')).toBeTruthy(); + + const modelBRow = screen.getByText('Model B').closest('li')!; + fireEvent.click(within(modelBRow).getByRole('button', { name: 'move-up' })); + expect( + [...screen.getByRole('list').querySelectorAll('strong')].map( + element => element.textContent + ) + ).toEqual(['Model B', 'Model A']); + const reorderedModelBRow = screen.getByText('Model B').closest('li')!; + fireEvent.click( + within(reorderedModelBRow).getByRole('checkbox', { + name: 'disable-model', + }) + ); + expect( + within(screen.getByText('Model B').closest('li')!).getByText('disabled') + ).toBeTruthy(); + + fireEvent.click(screen.getByText('use-custom')); + expect(screen.queryByText('Model A')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'add-model' })); + const modelId = screen.getByPlaceholderText('model-id'); + fireEvent.change(modelId, { target: { value: 'custom-chat' } }); + const modelDialog = screen.getAllByRole('dialog').at(-1)!; + fireEvent.click( + within(modelDialog).getByRole('button', { name: 'add-model' }) ); - try { - logByokError('byok', error); - expect(warn).toHaveBeenCalledWith('byok', { - name: 'Error', - code: 'BAD_REQUEST', - status: 400, - type: 'bad_request', - }); - expect(JSON.stringify(warn.mock.calls)).not.toContain('token=a+b%2F=='); - } finally { - warn.mockRestore(); - } + expect(screen.getByText('custom-chat')).toBeTruthy(); + }); + + test('requires model verification before saving the selected models', async () => { + type MockOperation = { + query: symbol; + variables?: { + input?: { definition?: { models?: unknown[] } }; + }; + }; + let probePasses = false; + const gql = vi.fn(async ({ query }: MockOperation) => { + if (query === probeMutation) { + return { + probeWorkspaceByokDraft: { + definitionFingerprint: 'fingerprint', + stale: false, + connection: { kind: 'verified' }, + models: [ + { + modelId: 'model-a', + checks: [ + { + operation: 'chat', + status: { kind: probePasses ? 'verified' : 'failed' }, + }, + ], + }, + ], + }, + }; + } + if (query === createMutation) { + return { createWorkspaceByokProfile: { profileId: 'profile-1' } }; + } + throw new Error('Unexpected GraphQL operation'); + }); + render( + + ); + + fireEvent.change(document.querySelector('input[type="password"]')!, { + target: { value: 'secret' }, + }); + fireEvent.click(screen.getByText('connect')); + + await waitFor(() => expect(gql).toHaveBeenCalledTimes(1)); + expect(gql.mock.calls.some(call => call[0].query === createMutation)).toBe( + false + ); + + probePasses = true; + fireEvent.click(screen.getByText('connect')); + await waitFor(() => expect(gql).toHaveBeenCalledTimes(3)); + const createCall = gql.mock.calls.find( + call => call[0].query === createMutation + ); + expect(createCall?.[0].variables?.input?.definition?.models).toEqual([ + { + modelId: 'model-a', + enabled: true, + capabilities: [textCapability], + }, + ]); + }); + + test('does not accept a verified connection when no model check ran', async () => { + type MockOperation = { + query: symbol; + variables?: { input?: { checks?: unknown[] } }; + }; + const gql = vi.fn(async ({ query }: MockOperation) => { + if (query === probeMutation) { + return { + probeWorkspaceByokDraft: { + definitionFingerprint: 'fingerprint', + stale: false, + connection: { kind: 'verified' }, + models: [], + }, + }; + } + if (query === createMutation) { + return { createWorkspaceByokProfile: { profileId: 'profile-1' } }; + } + throw new Error('Unexpected GraphQL operation'); + }); + render( + + ); + + fireEvent.change(document.querySelector('input[type="password"]')!, { + target: { value: 'secret' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'disable-model' })); + fireEvent.click(screen.getByText('connect')); + + await waitFor(() => expect(screen.getByText('failed')).toBeTruthy()); + + const probeCall = gql.mock.calls.find( + ([operation]) => operation.query === probeMutation + ); + expect(probeCall?.[0].variables?.input?.checks).toEqual([]); + expect( + gql.mock.calls.some(([operation]) => operation.query === createMutation) + ).toBe(false); + }); + + test('preserves definition version and server revision while editing', async () => { + type MockOperation = { + query: symbol; + variables?: { input?: Record }; + }; + const gql = vi.fn(async ({ query }: MockOperation) => { + if (query === probeMutation) { + return { + probeWorkspaceByokDraft: { + definitionFingerprint: 'fingerprint', + stale: false, + connection: { kind: 'verified' }, + models: [ + { + modelId: 'model-a', + checks: [{ operation: 'chat', status: { kind: 'verified' } }], + }, + ], + }, + }; + } + if (query === replaceMutation) { + return { replaceWorkspaceByokProfile: { profileId: 'profile-1' } }; + } + throw new Error('Unexpected GraphQL operation'); + }); + render( + + ); + + fireEvent.click(screen.getByText('save-changes')); + await waitFor(() => expect(gql).toHaveBeenCalledTimes(2)); + const replaceCall = gql.mock.calls.find( + call => call[0].query === replaceMutation + ); + expect(replaceCall?.[0].variables?.input).toMatchObject({ + expectedRevision: 7, + definition: { version: 3 }, + }); }); }); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.tsx index a75c803df4..55b37a3c13 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/index.tsx @@ -6,10 +6,10 @@ import { import { WorkspaceServerService } from '@affine/core/modules/cloud'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { - ByokKeyStorage, - clearWorkspaceByokConfigsMutation as clearByokMutation, - deleteWorkspaceByokConfigMutation as deleteByokMutation, + deleteWorkspaceByokProfileMutation, type GraphQLQuery, + probeWorkspaceByokProfileMutation, + reorderWorkspaceByokProfilesMutation, ServerDeploymentType, workspaceByokSettingsQuery as byokSettingsQuery, } from '@affine/graphql'; @@ -29,27 +29,12 @@ import { readLocalKeys, reorderLocalKeys, } from './local-storage'; -import { byokT } from './metadata'; -import type { - ByokKey, - ByokSettings, - ByokStorage, - ByokUsagePoint, - GqlFn, -} from './types'; +import { byokT, capabilitiesFor } from './metadata'; +import { probeChecks } from './model-utils'; +import type { ByokKey, ByokSettings, ByokUsagePoint, GqlFn } from './types'; +import { ByokStorage } from './types'; import { UsagePanel } from './usage'; -const reorderByokMutation = { - id: 'reorderWorkspaceByokConfigsMutation', - op: 'reorderWorkspaceByokConfigs', - query: `mutation reorderWorkspaceByokConfigs($input: ReorderWorkspaceByokConfigsInput!) { - reorderWorkspaceByokConfigs(input: $input) { - id - sortOrder - } - }`, -} satisfies GraphQLQuery; - export const WorkspaceByokSetting = () => { const t = useI18n(); const workspace = useService(WorkspaceService).workspace; @@ -59,6 +44,7 @@ export const WorkspaceByokSetting = () => { const [localKeys, setLocalKeys] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [editingKey, setEditingKey] = useState(null); + const [testingKeyId, setTestingKeyId] = useState(null); const [draggingKey, setDraggingKey] = useState<{ id: string; storage: ByokStorage; @@ -83,8 +69,27 @@ export const WorkspaceByokSetting = () => { localByokStorageSupported(), readLocalKeys(workspace.id), ]); + const serverKeys = data.workspace.byokSettings.profiles.map(profile => { + const key: ByokKey = { + id: profile.profileId, + provider: profile.provider, + name: profile.name, + description: profile.description, + storage: ByokStorage.server, + configured: true, + enabled: profile.enabled, + sortOrder: profile.sortOrder, + revision: profile.revision, + definition: profile.definition, + capabilities: [], + validation: profile.validation, + }; + key.capabilities = capabilitiesFor(key); + return key; + }); setSettings({ ...data.workspace.byokSettings, + keys: serverKeys, localStorageSupported: data.workspace.byokSettings.localEntitled && localStorageSupported, }); @@ -105,7 +110,7 @@ export const WorkspaceByokSetting = () => { const keys = useMemo(() => { return [...localKeys, ...(settings?.keys ?? [])].toSorted((a, b) => { if (a.storage !== b.storage) { - return a.storage === ByokKeyStorage.local ? -1 : 1; + return a.storage === ByokStorage.local ? -1 : 1; } return a.sortOrder - b.sortOrder; }); @@ -123,26 +128,35 @@ export const WorkspaceByokSetting = () => { if (!settings) { return; } - if (!workspaceServer.server && settings.serverEntitled) { - return; - } + const deletions: Promise[] = []; if (settings.serverEntitled && workspaceServer.server) { const gql = workspaceServer.server.gql as GqlFn; - await gql({ - query: clearByokMutation, - variables: { workspaceId: workspace.id }, - }); + deletions.push( + ...settings.keys.map(key => + gql({ + query: deleteWorkspaceByokProfileMutation, + variables: { workspaceId: workspace.id, profileId: key.id }, + }) + ) + ); } if (settings.localStorageSupported) { - await clearLocalKeys(workspace.id); + deletions.push(clearLocalKeys(workspace.id)); } - setLocalKeys([]); + const results = await Promise.allSettled(deletions); await load(); + if ( + results.some( + result => result.status === 'rejected' || result.value === false + ) + ) { + throw new Error('Some BYOK profiles could not be deleted'); + } }, [load, settings, workspace.id, workspaceServer.server]); const deleteKey = useCallback( async (key: ByokKey) => { - if (key.storage === ByokKeyStorage.local) { + if (key.storage === ByokStorage.local) { await deleteLocalKey(workspace.id, key.id); setLocalKeys(await readLocalKeys(workspace.id)); return; @@ -154,14 +168,39 @@ export const WorkspaceByokSetting = () => { }) => Promise) | undefined; await gql?.({ - query: deleteByokMutation, - variables: { workspaceId: workspace.id, id: key.id }, + query: deleteWorkspaceByokProfileMutation, + variables: { workspaceId: workspace.id, profileId: key.id }, }); await load(); }, [load, workspace.id, workspaceServer.server] ); + const testKey = useCallback( + async (key: ByokKey) => { + if (key.storage !== ByokStorage.server || !workspaceServer.server) { + return; + } + setTestingKeyId(key.id); + try { + await (workspaceServer.server.gql as GqlFn)({ + query: probeWorkspaceByokProfileMutation, + variables: { + input: { + workspaceId: workspace.id, + profileId: key.id, + checks: probeChecks(key.definition.models, false), + }, + }, + }); + await load(); + } finally { + setTestingKeyId(null); + } + }, + [load, workspace.id, workspaceServer.server] + ); + const reorderKey = useCallback( async (targetKey: ByokKey) => { if (!draggingKey || draggingKey.id === targetKey.id) { @@ -187,38 +226,44 @@ export const WorkspaceByokSetting = () => { nextBucket.splice(toIndex, 0, moved); const nextBucketIds = nextBucket.map(key => key.id); - if (targetKey.storage === ByokKeyStorage.local) { + if (targetKey.storage === ByokStorage.local) { setLocalKeys(await reorderLocalKeys(workspace.id, nextBucketIds)); - return; - } - - const gql = workspaceServer.server?.gql as - | ((input: { - query: GraphQLQuery; - variables?: Record; - }) => Promise) - | undefined; - await gql?.({ - query: reorderByokMutation, - variables: { - input: { - workspaceId: workspace.id, - storage: ByokKeyStorage.server, - ids: nextBucketIds, + } else if (workspaceServer.server) { + if (nextBucket.some(key => key.revision === undefined)) { + notify.error({ + title: byokT(t, 'notify.reload-required.title'), + message: byokT(t, 'notify.reload-required.message'), + }); + await load(); + return; + } + await (workspaceServer.server.gql as GqlFn)({ + query: reorderWorkspaceByokProfilesMutation, + variables: { + input: { + workspaceId: workspace.id, + profiles: nextBucket.flatMap(key => + key.revision === undefined + ? [] + : [ + { + profileId: key.id, + expectedRevision: key.revision, + }, + ] + ), + }, }, - }, - }); - await load(); + }); + await load(); + } }, [draggingKey, keys, load, t, workspace.id, workspaceServer.server] ); if (!settings) { return ( - + ); } @@ -226,7 +271,7 @@ export const WorkspaceByokSetting = () => { return ( <> @@ -237,13 +282,7 @@ export const WorkspaceByokSetting = () => { {byokT(t, 'locked.description')} -
- {settings.entitlementRequired.map(plan => ( - - {plan} - - ))} -
+
@@ -252,21 +291,9 @@ export const WorkspaceByokSetting = () => { return ( <> - +
- {settings.hasAiPlan ? ( -
-
{byokT(t, 'notice.title')}
-
- {byokT(t, 'notice.description')} -
-
- ) : null} -
@@ -289,6 +316,7 @@ export const WorkspaceByokSetting = () => { {keys.length ? ( { setEditingKey(key); setModalOpen(true); @@ -302,6 +330,15 @@ export const WorkspaceByokSetting = () => { }); }); }} + onTest={key => { + testKey(key).catch(error => { + logByokError('Failed to test BYOK provider', error); + notify.error({ + title: byokT(t, 'notify.test-failed.title'), + message: byokT(t, 'notify.operation-failed.message'), + }); + }); + }} onDragStart={key => { setDraggingKey({ id: key.id, storage: key.storage }); }} @@ -326,7 +363,7 @@ export const WorkspaceByokSetting = () => { )}
- + void; onDelete: (key: ByokKey) => void; + onTest: (key: ByokKey) => void; onDragStart: (key: ByokKey) => void; onDragEnd: () => void; onDrop: (key: ByokKey) => void; @@ -72,6 +76,18 @@ export const KeyList = ({
+ {key.storage === ByokStorage.server ? ( + + ) : null} { - test.each([ - [false, false, 'endpoint.custom-disabled'], - [true, false, 'endpoint.private-disabled'], - [true, true, null], - ] as const)( - 'maps custom=%s private=%s to %s', - (customEndpointSupported, privateEndpointSupported, expected) => { - expect( - endpointHintKey(customEndpointSupported, privateEndpointSupported) - ).toBe(expected); - } - ); -}); - -describe('shouldShowEndpoint', () => { - test.each([ - [true, false, true], - [true, true, true], - [false, true, true], - [false, false, false], - ])( - 'self-hosted %s with custom endpoint support %s returns %s', - (isSelfHosted, customEndpointSupported, expected) => { - expect(shouldShowEndpoint(isSelfHosted, customEndpointSupported)).toBe( - expected - ); - } - ); -}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.ts index b8b7d48494..fd24fb63ce 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/metadata.ts @@ -1,7 +1,7 @@ -import { ByokKeyStorage, ByokProvider } from '@affine/graphql'; +import { ByokProvider } from '@affine/graphql'; import type { I18nInstance } from '@affine/i18n'; -import type { ByokKey, ByokStorage } from './types'; +import { type ByokKey, ByokStorage } from './types'; export function byokT( t: I18nInstance, @@ -19,7 +19,7 @@ export const providerLabels: Record = { }; export function storageLabel(t: I18nInstance, storage: ByokStorage) { - return storage === ByokKeyStorage.local + return storage === ByokStorage.local ? byokT(t, 'storage.local') : byokT(t, 'storage.server'); } @@ -44,26 +44,20 @@ export function shouldShowEndpoint( return isSelfHosted || customEndpointSupported; } -export function capabilitiesFor(provider: ByokProvider, storage: ByokStorage) { - switch (provider) { - case ByokProvider.openai: - return ['Text', 'Image input', 'Actions', 'Image generate']; - case ByokProvider.anthropic: - return ['Text', 'Image input']; - case ByokProvider.gemini: - return storage === ByokKeyStorage.server - ? [ - 'Text', - 'Image input', - 'Actions', - 'Image generate', - 'Transcript', - 'Indexing', - ] - : ['Text', 'Image input', 'Actions', 'Image generate']; - case ByokProvider.fal: - return ['Image generate']; +export function capabilitiesFor(key: Pick) { + const capabilities = key.definition.models.flatMap(model => + model.enabled ? model.capabilities : [] + ); + const labels = new Set(); + for (const capability of capabilities) { + if (capability.output.includes('text')) labels.add('Text'); + if (capability.input.includes('image')) labels.add('Image input'); + if (capability.output.includes('image')) labels.add('Image generate'); + if (capability.features.includes('tools')) labels.add('Actions'); + if (capability.input.includes('audio')) labels.add('Transcript'); + if (capability.output.includes('embedding')) labels.add('Indexing'); } + return [...labels]; } export function capabilityLabel(t: I18nInstance, capability: string) { @@ -121,7 +115,7 @@ export const capabilityRows = [ icon: 'transcript', providers: [ByokProvider.gemini], coverageCapabilities: ['Transcript'], - storage: ByokKeyStorage.server, + storage: ByokStorage.server, }, { titleKey: 'feature.workspace-indexing.title', @@ -130,7 +124,7 @@ export const capabilityRows = [ icon: 'indexing', providers: [ByokProvider.gemini], coverageCapabilities: ['Indexing'], - storage: ByokKeyStorage.server, + storage: ByokStorage.server, }, ] as const; @@ -145,16 +139,13 @@ function formatDate(value?: string | null) { } export function rowDescription(t: I18nInstance, key: ByokKey) { - const failed = formatDate(key.lastErrorAt); - const used = formatDate(key.lastUsedAt); - const today = formatDate(new Date().toISOString()); - const activity = failed - ? byokT(t, 'row.activity.failed', { date: failed }) - : used - ? used === today - ? byokT(t, 'row.activity.used-today') - : byokT(t, 'row.activity.used', { date: used }) - : byokT(t, 'row.activity.unused'); + const tested = formatDate(key.validation?.connection.testedAt); + const activity = + key.validation?.connection.kind === 'failed' + ? byokT(t, 'row.activity.failed', { date: tested ?? '' }) + : key.validation?.connection.kind === 'verified' + ? byokT(t, 'status.key-verified') + : byokT(t, 'row.activity.unused'); return [storageLabel(t, key.storage), activity, key.description] .filter(Boolean) diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-editor-modal.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-editor-modal.tsx new file mode 100644 index 0000000000..a106282363 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-editor-modal.tsx @@ -0,0 +1,256 @@ +import { Button, Checkbox, Input, Modal } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { useEffect, useMemo, useState } from 'react'; + +import * as styles from './index.css'; +import { byokT } from './metadata'; +import { + capabilitiesForUseCases, + type catalogModels, + type ModelDeclaration, + modelUseCases, + type UseCase, + useCases, +} from './model-utils'; + +export const ModelEditorModal = ({ + open, + customEndpoint, + catalog, + models, + editingModel, + onOpenChange, + onSubmit, +}: { + open: boolean; + customEndpoint: boolean; + catalog: ReturnType; + models: ModelDeclaration[]; + editingModel: ModelDeclaration | null; + onOpenChange: (open: boolean) => void; + onSubmit: (models: ModelDeclaration[]) => void; +}) => { + const t = useI18n(); + const [search, setSearch] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + const [modelId, setModelId] = useState(''); + const [selectedUseCases, setSelectedUseCases] = useState(['chat']); + + useEffect(() => { + if (!open) return; + setSearch(''); + setSelectedIds([]); + setModelId(editingModel?.modelId ?? ''); + setSelectedUseCases(editingModel ? modelUseCases(editingModel) : ['chat']); + }, [editingModel, open]); + + const availableCatalog = useMemo(() => { + return catalog + .filter(item => !models.some(model => model.modelId === item.modelId)) + .sort( + (left, right) => Number(right.recommended) - Number(left.recommended) + ); + }, [catalog, models]); + const available = useMemo(() => { + const query = search.trim().toLocaleLowerCase(); + return availableCatalog.filter( + item => + !query || + item.displayName.toLocaleLowerCase().includes(query) || + item.modelId.toLocaleLowerCase().includes(query) + ); + }, [availableCatalog, search]); + + const submit = () => { + if (customEndpoint) { + onSubmit([ + { + modelId: modelId.trim(), + enabled: editingModel?.enabled ?? true, + capabilities: capabilitiesForUseCases(editingModel, selectedUseCases), + }, + ]); + } else { + onSubmit( + selectedIds.flatMap(id => { + const model = catalog.find(item => item.modelId === id); + return model + ? [ + { + modelId: model.modelId, + enabled: true, + capabilities: model.capabilities, + }, + ] + : []; + }) + ); + } + onOpenChange(false); + }; + + const normalizedModelId = modelId.trim(); + const duplicateModelId = models.some( + model => model !== editingModel && model.modelId === normalizedModelId + ); + const valid = customEndpoint + ? !!normalizedModelId && !duplicateModelId && selectedUseCases.length > 0 + : selectedIds.length > 0; + + return ( + + {customEndpoint ? ( +
+ +
+ + {byokT(t, 'model.use-this-for')} + +
+ {useCases.map(useCase => ( + + setSelectedUseCases( + checked + ? [...selectedUseCases, useCase.id] + : selectedUseCases.filter(item => item !== useCase.id) + ) + } + /> + ))} +
+
+
+ ) : ( +
+ {availableCatalog.length > 6 ? ( + + ) : null} +
+ {available.length ? ( + available.map(model => { + const modelUses = modelUseCases({ + modelId: model.modelId, + enabled: true, + capabilities: model.capabilities, + }); + const visibleUses = modelUses.slice(0, 3).flatMap(useCase => { + const item = useCases.find(item => item.id === useCase); + return item ? [byokT(t, item.labelKey)] : []; + }); + if (modelUses.length > 3) { + visibleUses.push(`+${modelUses.length - 3}`); + } + return ( + + ); + }) + ) : ( +
+ {byokT( + t, + search ? 'models.no-search-results' : 'models.all-added' + )} +
+ )} +
+
+ )} +
+ + +
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-selector.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-selector.tsx new file mode 100644 index 0000000000..0a80827c76 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-selector.tsx @@ -0,0 +1,227 @@ +import { + Button, + DragHandle, + IconButton, + Menu, + MenuItem, + Switch, +} from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; +import { useState } from 'react'; + +import * as styles from './index.css'; +import { byokT } from './metadata'; +import { ModelEditorModal } from './model-editor-modal'; +import { + type catalogModels, + type ModelDeclaration, + modelUseCases, + useCases, +} from './model-utils'; +import type { ByokKey } from './types'; + +export const ModelSelector = ({ + customEndpoint, + catalog, + models, + validation, + onChange, +}: { + customEndpoint: boolean; + catalog: ReturnType; + models: ModelDeclaration[]; + validation?: ByokKey['validation']; + onChange: (models: ModelDeclaration[]) => void; +}) => { + const t = useI18n(); + const [editorOpen, setEditorOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [draggingIndex, setDraggingIndex] = useState(null); + + const update = (index: number, model: ModelDeclaration) => { + onChange(models.map((current, i) => (i === index ? model : current))); + }; + const move = (index: number, offset: number) => { + const target = index + offset; + if (target < 0 || target >= models.length) return; + const next = [...models]; + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }; + const drop = (targetIndex: number) => { + if (draggingIndex === null || draggingIndex === targetIndex) return; + const next = [...models]; + const [dragged] = next.splice(draggingIndex, 1); + next.splice(targetIndex, 0, dragged); + onChange(next); + setDraggingIndex(null); + }; + const evidence = (modelId: string) => { + const checks = validation?.models.find( + model => model.modelId === modelId + )?.checks; + if (!checks?.length) return byokT(t, 'model.status.not-tested'); + const verified = checks.filter( + check => check.status.kind === 'verified' + ).length; + if (verified === checks.length) return byokT(t, 'model.status.verified'); + if (verified === 0) return byokT(t, 'model.status.failed'); + return byokT(t, 'model.status.partially-verified', { + verified, + total: checks.length, + }); + }; + + return ( + <> +
+ + {byokT(t, 'models.description.order')} + + +
+ {models.length ? ( +
    + {models.map((model, index) => { + const catalogModel = catalog.find( + item => item.modelId === model.modelId + ); + const selected = modelUseCases(model); + return ( +
  1. event.preventDefault()} + onDrop={event => { + event.preventDefault(); + drop(index); + }} + > +
    setDraggingIndex(index)} + onDragEnd={() => setDraggingIndex(null)} + > + +
    +
    + {catalogModel?.displayName ?? model.modelId} + {catalogModel?.displayName ? ( + {model.modelId} + ) : null} + + {selected.slice(0, 3).map(useCase => { + const item = useCases.find(item => item.id === useCase); + return item ? ( + + {byokT(t, item.labelKey)} + + ) : null; + })} + {selected.length > 3 ? ( + +{selected.length - 3} + ) : null} + +
    + + {model.enabled + ? evidence(model.modelId) + : byokT(t, 'model.status.disabled')} + + update(index, { ...model, enabled })} + /> + + {customEndpoint ? ( + { + setEditingIndex(index); + setEditorOpen(true); + }} + > + {byokT(t, 'action.edit')} + + ) : null} + move(index, -1)} + > + {byokT(t, 'action.move-up')} + + move(index, 1)} + > + {byokT(t, 'action.move-down')} + + + onChange(models.filter((_, i) => i !== index)) + } + > + {byokT(t, 'action.remove')} + + + } + > + } + /> + +
  2. + ); + })} +
+ ) : ( +
{byokT(t, 'models.empty')}
+ )} + { + setEditorOpen(open); + if (!open) setEditingIndex(null); + }} + onSubmit={next => { + if (editingIndex === null) { + onChange([...models, ...next]); + } else if (next[0]) { + update(editingIndex, next[0]); + } + setEditingIndex(null); + }} + /> + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.spec.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.spec.ts new file mode 100644 index 0000000000..016b0d7a9b --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; + +import { + capabilitiesForUseCases, + type ModelDeclaration, + modelUseCases, +} from './model-utils'; + +describe('BYOK model capabilities', () => { + test('maps richer catalog capabilities by minimum requirements', () => { + const model: ModelDeclaration = { + modelId: 'multimodal-tools', + enabled: true, + capabilities: [ + { + input: ['text', 'image'], + output: ['text'], + features: ['tools'], + attachmentKinds: ['image'], + attachmentSources: ['url', 'data', 'bytes', 'file_handle'], + }, + ], + }; + + expect(modelUseCases(model)).toEqual(['chat', 'actions', 'vision']); + }); + + test('preserves a rich capability when its represented uses stay selected', () => { + const capability = { + input: ['text', 'image'], + output: ['text'], + features: ['tools'], + attachmentKinds: ['image'], + attachmentSources: ['url', 'data', 'bytes', 'file_handle'], + }; + const model: ModelDeclaration = { + modelId: 'multimodal-tools', + enabled: true, + capabilities: [capability], + }; + + expect( + capabilitiesForUseCases(model, ['chat', 'actions', 'vision']) + ).toEqual([capability]); + }); +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.ts new file mode 100644 index 0000000000..08b8ade5ae --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.ts @@ -0,0 +1,150 @@ +import type { ByokProvider } from '@affine/graphql'; + +import type { ByokDefinition, ByokSettings } from './types'; + +export type ModelDeclaration = ByokDefinition['models'][number]; +type Capability = ModelDeclaration['capabilities'][number]; +export type UseCase = + | 'chat' + | 'actions' + | 'structured' + | 'vision' + | 'image' + | 'transcript' + | 'embedding' + | 'rerank'; + +export const useCases: { id: UseCase; labelKey: string }[] = [ + { id: 'chat', labelKey: 'model.use.chat' }, + { id: 'actions', labelKey: 'model.use.actions' }, + { id: 'structured', labelKey: 'model.use.structured' }, + { id: 'vision', labelKey: 'model.use.vision' }, + { id: 'image', labelKey: 'model.use.image' }, + { id: 'transcript', labelKey: 'model.use.transcript' }, + { id: 'embedding', labelKey: 'model.use.embedding' }, + { id: 'rerank', labelKey: 'model.use.rerank' }, +]; + +export function capabilityForUseCase(useCase: UseCase): Capability { + switch (useCase) { + case 'actions': + return modelCapability(['text'], ['text'], ['tools']); + case 'structured': + return modelCapability(['text'], ['structured']); + case 'vision': + return modelCapability( + ['text', 'image'], + ['text'], + [], + ['image'], + ['url', 'data', 'bytes', 'file_handle'] + ); + case 'image': + return modelCapability(['text'], ['image']); + case 'transcript': + return modelCapability( + ['audio'], + ['structured'], + [], + ['audio'], + ['url', 'data', 'bytes', 'file_handle'] + ); + case 'embedding': + return modelCapability(['text'], ['embedding']); + case 'rerank': + return modelCapability(['text'], ['rerank']); + default: + return modelCapability(['text'], ['text']); + } +} + +function modelCapability( + input: string[], + output: string[], + features: string[] = [], + attachmentKinds: string[] = [], + attachmentSources: string[] = [] +): Capability { + return { input, output, features, attachmentKinds, attachmentSources }; +} + +function matchesCapability(value: Capability, useCase: UseCase) { + const expected = capabilityForUseCase(useCase); + const fields = [ + 'input', + 'output', + 'features', + 'attachmentKinds', + 'attachmentSources', + ] as const; + return fields.every(field => + expected[field].every(item => value[field].includes(item)) + ); +} + +export function modelUseCases(model: ModelDeclaration) { + return useCases + .filter(({ id }) => + model.capabilities.some(item => matchesCapability(item, id)) + ) + .map(({ id }) => id); +} + +export function capabilitiesForUseCases( + model: ModelDeclaration | null, + selectedUseCases: UseCase[] +) { + const selected = new Set(selectedUseCases); + const capabilities = (model?.capabilities ?? []).filter(capability => { + const represented = useCases + .map(({ id }) => id) + .filter(useCase => matchesCapability(capability, useCase)); + return ( + represented.length > 0 && + represented.every(useCase => selected.has(useCase)) + ); + }); + + for (const useCase of selectedUseCases) { + if ( + !capabilities.some(capability => matchesCapability(capability, useCase)) + ) { + capabilities.push(capabilityForUseCase(useCase)); + } + } + return capabilities; +} + +export function probeChecks(models: ModelDeclaration[], includeImage: boolean) { + return models + .filter(model => model.enabled) + .flatMap(model => + modelUseCases(model) + .filter( + useCase => + !['vision', 'transcript'].includes(useCase) && + (useCase !== 'image' || includeImage) + ) + .map(useCase => ({ + modelId: model.modelId, + operation: useCase === 'actions' ? 'tools' : useCase, + })) + ); +} + +export function catalogModels(settings: ByokSettings, provider: ByokProvider) { + return ( + settings.catalog.providers.find(item => item.provider === provider) + ?.models ?? [] + ); +} + +export function defaultModels(settings: ByokSettings, provider: ByokProvider) { + const catalog = catalogModels(settings, provider); + const selected = catalog.filter(model => model.recommended); + return (selected.length ? selected : catalog.slice(0, 1)).map(model => ({ + modelId: model.modelId, + enabled: true, + capabilities: model.capabilities, + })); +} diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/types.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/types.ts index ea5e651bc2..86cc0e854b 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/types.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/types.ts @@ -1,76 +1,64 @@ import { - type ByokKeyStorage, - type ByokKeyTestStatus, type ByokProvider, type GraphQLQuery, type QueryOptions, type QueryResponse, + type WorkspaceByokSettingsQuery, } from '@affine/graphql'; -export type ByokStorage = ByokKeyStorage; +export const ByokStorage = { + server: 'server', + local: 'local', +} as const; +export type ByokStorage = (typeof ByokStorage)[keyof typeof ByokStorage]; -export type ByokKey = { +export type ByokDefinition = + WorkspaceByokSettingsQuery['workspace']['byokSettings']['profiles'][number]['definition']; + +type ByokKeyBase = { id: string; provider: ByokProvider; name: string; description?: string | null; - storage: ByokStorage; configured: boolean; enabled: boolean; - endpoint?: string | null; - endpointEditable: boolean; sortOrder: number; + definition: ByokDefinition; capabilities: string[]; - testStatus: ByokKeyTestStatus; - disabledReason?: string | null; - lastTestedAt?: string | null; - lastTestError?: string | null; - lastUsedAt?: string | null; - lastErrorAt?: string | null; - lastError?: string | null; + validation?: WorkspaceByokSettingsQuery['workspace']['byokSettings']['profiles'][number]['validation']; }; +export type ByokKey = ByokKeyBase & + ( + | { storage: typeof ByokStorage.server; revision: number } + | { storage: typeof ByokStorage.local; revision?: never } + ); + export type LocalByokKeyInput = Pick< ByokKey, | 'id' | 'provider' | 'name' | 'description' - | 'endpoint' | 'sortOrder' | 'enabled' + | 'definition' +> & { credential: string }; + +export type ByokSettings = Omit< + WorkspaceByokSettingsQuery['workspace']['byokSettings'], + 'profiles' > & { - apiKey: string; -}; - -export type ByokSettings = { - workspaceId: string; - entitled: boolean; - serverEntitled: boolean; - localEntitled: boolean; - entitlementRequired: string[]; keys: ByokKey[]; - allowedProviders: ByokProvider[]; localStorageSupported: boolean; - customEndpointSupported: boolean; - privateEndpointSupported: boolean; - hasAiPlan: boolean; - warnings: Array<{ - featureKind: string; - reason: string; - requiredProviders: ByokProvider[]; - }>; }; -export type ByokUsagePoint = { - date: string; - featureKind: string; - totalTokens: number; -}; +export type ByokUsagePoint = + WorkspaceByokSettingsQuery['workspace']['byokUsage'][number]; export type ByokTestResult = { ok: boolean; - status: ByokKey['testStatus']; + status: string; message?: string | null; }; @@ -78,15 +66,6 @@ export type GqlFn = ( input: QueryOptions ) => Promise>; -export type LocalByokPublicKey = { - id: string; - provider: ByokProvider; - name: string; - description?: string | null; - endpoint?: string | null; - endpointEditable?: boolean; - sortOrder?: number | null; - enabled?: boolean | null; +export type LocalByokPublicKey = Omit & { configured?: boolean; - testStatus?: ByokKey['testStatus']; }; diff --git a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx index b21f322def..ca2cf92ad2 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx @@ -19,9 +19,9 @@ import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs'; import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe'; import { AIDraftService, + AIModelService, AIToolsConfigService, } from '@affine/core/modules/ai-button'; -import { AIModelService } from '@affine/core/modules/ai-button/services/models'; import { EventSourceService, GraphQLService, @@ -197,9 +197,9 @@ export const Component = () => { content.notificationService = notificationService; content.aiDraftService = framework.get(AIDraftService); content.aiToolsConfigService = framework.get(AIToolsConfigService); + content.aiModelService = framework.get(AIModelService); content.serverService = framework.get(ServerService); content.subscriptionService = framework.get(SubscriptionService); - content.aiModelService = framework.get(AIModelService); content.onAISubscribe = handleAISubscribe; content.onOpenDoc = onOpenDoc; }, diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx index e86b237791..fee5956e8e 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx @@ -23,9 +23,9 @@ import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs'; import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe'; import { AIDraftService, + AIModelService, AIToolsConfigService, } from '@affine/core/modules/ai-button'; -import { AIModelService } from '@affine/core/modules/ai-button/services/models'; import { EventSourceService, GraphQLService, @@ -283,9 +283,9 @@ export const EditorChatPanel = ({ content.notificationService = notificationService; content.aiDraftService = framework.get(AIDraftService); content.aiToolsConfigService = framework.get(AIToolsConfigService); + content.aiModelService = framework.get(AIModelService); content.peekViewService = framework.get(PeekViewService); content.subscriptionService = framework.get(SubscriptionService); - content.aiModelService = framework.get(AIModelService); content.onAISubscribe = handleAISubscribe; content.width = sidebarWidthSignal; content.onOpenDoc = (docId: string, sessionId?: string) => { @@ -399,8 +399,8 @@ export const EditorChatPanel = ({ .notificationService=${notificationService} .affineWorkspaceDialogService=${framework.get(WorkspaceDialogService)} .aiToolsConfigService=${framework.get(AIToolsConfigService)} - .subscriptionService=${framework.get(SubscriptionService)} .aiModelService=${framework.get(AIModelService)} + .subscriptionService=${framework.get(SubscriptionService)} > `; diff --git a/packages/frontend/core/src/modules/ai-button/index.ts b/packages/frontend/core/src/modules/ai-button/index.ts index 26fbf585ce..56f10f8e80 100644 --- a/packages/frontend/core/src/modules/ai-button/index.ts +++ b/packages/frontend/core/src/modules/ai-button/index.ts @@ -1,6 +1,7 @@ export { AIButtonProvider } from './provider/ai-button'; export { AIButtonService } from './services/ai-button'; export { AIDraftService } from './services/ai-draft'; +export { AIModelService } from './services/models'; export { type AIToolsConfig, AIToolsConfigService, diff --git a/packages/frontend/core/src/modules/ai-button/services/models.spec.ts b/packages/frontend/core/src/modules/ai-button/services/models.spec.ts new file mode 100644 index 0000000000..251ebbbca0 --- /dev/null +++ b/packages/frontend/core/src/modules/ai-button/services/models.spec.ts @@ -0,0 +1,67 @@ +import { Framework } from '@toeverything/infra'; +import { EMPTY } from 'rxjs'; +import { describe, expect, test, vi } from 'vitest'; + +import { AIModelService } from './models'; + +describe('AIModelService', () => { + test('clears the previous model while a new scope is loading', async () => { + let resolveSecond: ((value: unknown) => void) | undefined; + const gql = vi + .fn() + .mockResolvedValueOnce({ + currentUser: { + copilot: { + routeOptions: { + choices: [ + { + id: 'model-a', + displayName: 'Model A', + available: true, + }, + ], + }, + }, + }, + }) + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveSecond = resolve; + }) + ); + const stored = new Map(); + const framework = new Framework(); + framework.service( + AIModelService, + () => + new AIModelService( + { + globalState: { + get: (key: string) => stored.get(key), + set: (key: string, value: unknown) => stored.set(key, value), + }, + } as never, + { gql } as never, + { + subscription: { ai$: EMPTY }, + } as never + ) + ); + const service = framework.provider().get(AIModelService); + + service.setScope('workspace-a', 'route-a'); + await vi.waitFor(() => expect(service.models.value).toHaveLength(1)); + service.setModel('model-a'); + + service.setScope('workspace-b', 'route-b'); + expect(service.models.value).toEqual([]); + expect(service.modelId.value).toBeUndefined(); + service.setModel('model-a'); + expect(stored.has('AIManagedRouteTarget:workspace-b:route-b')).toBe(false); + + resolveSecond?.({ + currentUser: { copilot: { routeOptions: { choices: [] } } }, + }); + }); +}); diff --git a/packages/frontend/core/src/modules/ai-button/services/models.ts b/packages/frontend/core/src/modules/ai-button/services/models.ts index b50b76c40c..ddf69c00f4 100644 --- a/packages/frontend/core/src/modules/ai-button/services/models.ts +++ b/packages/frontend/core/src/modules/ai-button/services/models.ts @@ -1,112 +1,109 @@ -import { getPromptModelsQuery, SubscriptionStatus } from '@affine/graphql'; -import { - createSignalFromObservable, - type Signal, -} from '@blocksuite/affine/shared/utils'; +import { getCopilotRouteOptionsQuery } from '@affine/graphql'; import { signal } from '@preact/signals-core'; -import { LiveData, Service } from '@toeverything/infra'; +import { Service } from '@toeverything/infra'; import type { GraphQLService, SubscriptionService } from '../../cloud'; import type { GlobalStateService } from '../../storage'; -const AI_MODEL_ID_KEY = 'AIModelId'; +const ROUTE_TARGET_KEY = 'AIManagedRouteTarget'; export interface AIModel { - name: string; id: string; - version: string; + name: string; category: string; - isPro: boolean; - isDefault: boolean; + version: string; + available: boolean; } export class AIModelService extends Service { - modelId: Signal; + readonly modelId = signal(undefined); + readonly models = signal([]); - models: Signal = signal([]); - - private readonly modelId$ = LiveData.from( - this.globalStateService.globalState.watch(AI_MODEL_ID_KEY), - undefined - ); + private workspaceId: string | undefined; + private routeId: string | undefined; + private requestId = 0; constructor( private readonly globalStateService: GlobalStateService, private readonly gqlService: GraphQLService, - private readonly subscriptionService: SubscriptionService + subscriptionService: SubscriptionService ) { super(); - - const { signal: modelId, cleanup } = createSignalFromObservable< - string | undefined - >(this.modelId$, undefined); - this.modelId = modelId; - this.disposables.push(cleanup); - - this.init().catch(err => { - console.error(err); + const subscription = subscriptionService.subscription.ai$.subscribe(() => { + if (this.workspaceId && this.routeId) { + this.load(this.workspaceId, this.routeId).catch(console.error); + } }); + this.disposables.push(() => subscription.unsubscribe()); } - resetModel = () => { - this.globalStateService.globalState.set(AI_MODEL_ID_KEY, undefined); - }; + setScope(workspaceId: string, routeId: string) { + if (workspaceId === this.workspaceId && routeId === this.routeId) return; + this.workspaceId = workspaceId; + this.routeId = routeId; + this.models.value = []; + this.modelId.value = undefined; + this.load(workspaceId, routeId).catch(console.error); + } - setModel = (modelId: string) => { - const isSubscribed = - this.subscriptionService.subscription.ai$.value?.status === - SubscriptionStatus.Active; - const model = this.models.value.find(model => model.id === modelId); - if (!isSubscribed && model?.isPro) { + resetModel() { + this.setModel(undefined); + } + + setModel(modelId: string | undefined) { + if (!this.workspaceId || !this.routeId) return; + if ( + modelId && + !this.models.value.find(model => model.id === modelId)?.available + ) { return; } - this.globalStateService.globalState.set(AI_MODEL_ID_KEY, modelId); - }; - - private readonly init = async () => { - await this.initModels(); - - // subscribe to ai purchase status - const sub = this.subscriptionService.subscription.ai$.subscribe( - subscription => { - const isSubscribed = subscription?.status === SubscriptionStatus.Active; - const model = this.models.value.find( - model => model.id === this.modelId.value - ); - if (!isSubscribed && model?.isPro) { - this.resetModel(); - } - } + this.modelId.value = modelId; + this.globalStateService.globalState.set( + this.storageKey(this.workspaceId, this.routeId), + modelId ); - this.disposables.push(() => sub.unsubscribe()); - }; + } - private readonly initModels = async (prompt?: string) => { - const promptName = prompt || 'Chat With AFFiNE AI'; - const models = await this.getModelsByPrompt(promptName); - if (models) { - const { defaultModel, optionalModels, proModels } = models; - this.models.value = optionalModels.map(model => { - const [category] = model.name.split(' '); - const version = model.name.slice(category.length + 1); - return { - name: model.name, - id: model.id, - version, - category, - isPro: proModels.some(proModel => proModel.id === model.id), - isDefault: model.id === defaultModel, - }; - }); + private async load(workspaceId: string, routeId: string) { + const requestId = ++this.requestId; + const result = await this.gqlService.gql({ + query: getCopilotRouteOptionsQuery, + variables: { promptName: routeId }, + }); + if (requestId !== this.requestId) return; + const options = result.currentUser?.copilot?.routeOptions; + if (!options) { + this.models.value = []; + this.modelId.value = undefined; + return; } - }; + this.models.value = options.choices.map(choice => { + const [category] = choice.displayName.split(' '); + return { + id: choice.id, + name: choice.displayName, + category, + version: choice.displayName.slice(category.length + 1), + available: choice.available, + }; + }); + const selected = this.globalStateService.globalState.get( + this.storageKey(workspaceId, routeId) + ); + const selectedAvailable = this.models.value.some( + model => model.id === selected && model.available + ); + this.modelId.value = selectedAvailable ? selected : undefined; + if (selected && !selectedAvailable) { + this.globalStateService.globalState.set( + this.storageKey(workspaceId, routeId), + undefined + ); + } + } - private readonly getModelsByPrompt = async (promptName: string) => { - return this.gqlService - .gql({ - query: getPromptModelsQuery, - variables: { promptName }, - }) - .then(res => res.currentUser?.copilot?.models); - }; + private storageKey(workspaceId: string, routeId: string) { + return `${ROUTE_TARGET_KEY}:${workspaceId}:${routeId}`; + } } diff --git a/packages/frontend/core/src/modules/media/entities/audio-transcription-job-store.spec.ts b/packages/frontend/core/src/modules/media/entities/audio-transcription-job-store.spec.ts index f3fe365391..092e95860e 100644 --- a/packages/frontend/core/src/modules/media/entities/audio-transcription-job-store.spec.ts +++ b/packages/frontend/core/src/modules/media/entities/audio-transcription-job-store.spec.ts @@ -69,10 +69,7 @@ describe('AudioTranscriptionJobStore transcript task API', () => { .mockResolvedValueOnce({ submitTranscriptTask: { id: 'task-1' } }) .mockResolvedValueOnce({ retryTranscriptTask: { id: 'task-2' } }) .mockResolvedValueOnce({ settleTranscriptTask: { id: 'task-2' } }); - const store = createStore(gql, async () => ({ - files: [file], - input: { strategy: 'gemini' }, - })); + const store = createStore(gql, async () => ({ files: [file] })); await store.submitTranscriptTask(); await store.retryTranscriptTask('task-1'); @@ -87,7 +84,7 @@ describe('AudioTranscriptionJobStore transcript task API', () => { workspaceId: 'workspace-1', blobId: 'blob-1', blobs: [file], - input: { strategy: 'gemini' }, + input: undefined, }, }) ); diff --git a/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx b/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx index b71c9bb612..431b0217d5 100644 --- a/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx @@ -6,9 +6,9 @@ import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-cha import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe'; import { AIDraftService, + AIModelService, AIToolsConfigService, } from '@affine/core/modules/ai-button'; -import { AIModelService } from '@affine/core/modules/ai-button/services/models'; import { ServerService, SubscriptionService } from '@affine/core/modules/cloud'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; @@ -36,8 +36,8 @@ export const AIChatBlockPeekView = ({ const affineWorkspaceDialogService = framework.get(WorkspaceDialogService); const aiDraftService = framework.get(AIDraftService); const aiToolsConfigService = framework.get(AIToolsConfigService); - const subscriptionService = framework.get(SubscriptionService); const aiModelService = framework.get(AIModelService); + const subscriptionService = framework.get(SubscriptionService); const handleAISubscribe = useAISubscribe(); return useMemo(() => { @@ -52,8 +52,8 @@ export const AIChatBlockPeekView = ({ affineWorkspaceDialogService, aiDraftService, aiToolsConfigService, - subscriptionService, aiModelService, + subscriptionService, handleAISubscribe ); return toReactNode(template); @@ -68,8 +68,8 @@ export const AIChatBlockPeekView = ({ affineWorkspaceDialogService, aiDraftService, aiToolsConfigService, - subscriptionService, aiModelService, + subscriptionService, handleAISubscribe, ]); }; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index a03223d906..ad726887a8 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,28 +1,28 @@ { - "ar": 92, - "ca": 89, - "da": 4, + "ar": 90, + "ca": 88, + "da": 3, "de": 98, - "el-GR": 88, + "el-GR": 86, "en": 100, - "es-AR": 88, - "es-CL": 89, - "es": 88, - "fa": 88, - "fr": 92, + "es-AR": 87, + "es-CL": 88, + "es": 86, + "fa": 86, + "fr": 90, "hi": 1, - "it": 89, - "ja": 88, - "kk": 96, - "ko": 89, - "nb-NO": 44, - "pl": 89, - "pt-BR": 88, - "ru": 90, - "sv-SE": 88, - "tr": 96, - "uk": 88, - "ur": 96, - "zh-Hans": 100, - "zh-Hant": 90 + "it": 88, + "ja": 86, + "kk": 93, + "ko": 87, + "nb-NO": 43, + "pl": 88, + "pt-BR": 86, + "ru": 88, + "sv-SE": 87, + "tr": 93, + "uk": 86, + "ur": 93, + "zh-Hans": 97, + "zh-Hant": 88 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 2f7d668985..b6cca38a26 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -5925,10 +5925,6 @@ export function useAFFiNEI18N(): { /** * `AI BYOK (Beta)` */ - ["com.affine.settings.workspace.byok.title-beta"](): string; - /** - * `AI BYOK` - */ ["com.affine.settings.workspace.byok.title"](): string; /** * `Loading provider keys.` @@ -5950,14 +5946,6 @@ export function useAFFiNEI18N(): { * `Upgrade this workspace to add provider keys and route AFFiNE AI through your own OpenAI, Anthropic, Gemini, or FAL account.` */ ["com.affine.settings.workspace.byok.locked.description"](): string; - /** - * `AI plan stays available` - */ - ["com.affine.settings.workspace.byok.notice.title"](): string; - /** - * `Local keys on this device are tried first. Workspace server keys follow, then AFFiNE AI plan routes when quota is available.` - */ - ["com.affine.settings.workspace.byok.notice.description"](): string; /** * `Provider keys` */ @@ -5983,13 +5971,25 @@ export function useAFFiNEI18N(): { */ ["com.affine.settings.workspace.byok.storage.server"](): string; /** - * `Local (this device)` + * `Available to workspace members.` */ - ["com.affine.settings.workspace.byok.storage.local-this-device"](): string; + ["com.affine.settings.workspace.byok.storage.server.description"](): string; /** - * `Local (Desktop only)` + * `Kept in this desktop device’s secure storage.` */ - ["com.affine.settings.workspace.byok.storage.local-desktop-only"](): string; + ["com.affine.settings.workspace.byok.storage.local.description"](): string; + /** + * `Available in the AFFiNE desktop app.` + */ + ["com.affine.settings.workspace.byok.storage.local.desktop-only"](): string; + /** + * `Secure local storage is not available on this device.` + */ + ["com.affine.settings.workspace.byok.storage.local.unavailable"](): string; + /** + * `Testing sends this key to the workspace server for this request only; it is not stored there.` + */ + ["com.affine.settings.workspace.byok.storage.local.test-disclosure"](): string; /** * `Disabled after failure` */ @@ -5998,10 +5998,6 @@ export function useAFFiNEI18N(): { * `Key verified` */ ["com.affine.settings.workspace.byok.status.key-verified"](): string; - /** - * `Key test failed` - */ - ["com.affine.settings.workspace.byok.status.key-test-failed"](): string; /** * `Text` */ @@ -6032,16 +6028,6 @@ export function useAFFiNEI18N(): { ["com.affine.settings.workspace.byok.row.activity.failed"](options: { readonly date: string; }): string; - /** - * `used {{date}}` - */ - ["com.affine.settings.workspace.byok.row.activity.used"](options: { - readonly date: string; - }): string; - /** - * `used today` - */ - ["com.affine.settings.workspace.byok.row.activity.used-today"](): string; /** * `not used yet` */ @@ -6113,17 +6099,69 @@ export function useAFFiNEI18N(): { readonly count: string; }): string; /** - * `Add provider key` + * `Connect AI provider` */ - ["com.affine.settings.workspace.byok.modal.add-title"](): string; + ["com.affine.settings.workspace.byok.modal.connect-title"](): string; /** - * `Edit provider key` + * `Manage provider` */ - ["com.affine.settings.workspace.byok.modal.edit-title"](): string; + ["com.affine.settings.workspace.byok.modal.manage-title"](): string; /** - * `Re-enter the API key and test it before saving changes.` + * `Choose where the key is stored, then select the models AFFiNE may use.` */ - ["com.affine.settings.workspace.byok.modal.description"](): string; + ["com.affine.settings.workspace.byok.modal.connect-description"](): string; + /** + * `Add models` + */ + ["com.affine.settings.workspace.byok.modal.add-model-title"](): string; + /** + * `Add custom model` + */ + ["com.affine.settings.workspace.byok.modal.add-custom-model-title"](): string; + /** + * `Edit model` + */ + ["com.affine.settings.workspace.byok.modal.edit-model-title"](): string; + /** + * `Choose one or more models this key may use.` + */ + ["com.affine.settings.workspace.byok.modal.catalog-model-description"](): string; + /** + * `Enter the endpoint model ID and choose its uses.` + */ + ["com.affine.settings.workspace.byok.modal.custom-model-description"](): string; + /** + * `Connection` + */ + ["com.affine.settings.workspace.byok.section.connection"](): string; + /** + * `Models` + */ + ["com.affine.settings.workspace.byok.section.models"](): string; + /** + * `Advanced details` + */ + ["com.affine.settings.workspace.byok.section.advanced"](): string; + /** + * `Models this key may use.` + */ + ["com.affine.settings.workspace.byok.models.description.selected"](): string; + /** + * `Among compatible models, enabled models are tried from top to bottom.` + */ + ["com.affine.settings.workspace.byok.models.description.order"](): string; + /** + * `No models added yet.` + */ + ["com.affine.settings.workspace.byok.models.empty"](): string; + /** + * `All available models have been added.` + */ + ["com.affine.settings.workspace.byok.models.all-added"](): string; + /** + * `No matching models.` + */ + ["com.affine.settings.workspace.byok.models.no-search-results"](): string; /** * `Provider` */ @@ -6137,9 +6175,9 @@ export function useAFFiNEI18N(): { */ ["com.affine.settings.workspace.byok.field.description"](): string; /** - * `Key storage` + * `Provider enabled` */ - ["com.affine.settings.workspace.byok.field.storage"](): string; + ["com.affine.settings.workspace.byok.field.provider-enabled"](): string; /** * `API key` */ @@ -6148,6 +6186,10 @@ export function useAFFiNEI18N(): { * `Endpoint` */ ["com.affine.settings.workspace.byok.field.endpoint"](): string; + /** + * `Model ID` + */ + ["com.affine.settings.workspace.byok.field.model-id"](): string; /** * `Custom endpoints are disabled by the server administrator. In Self-hosted Admin, enable copilot.byok.allowCustomEndpoint.` */ @@ -6157,29 +6199,176 @@ export function useAFFiNEI18N(): { */ ["com.affine.settings.workspace.byok.endpoint.private-disabled"](): string; /** - * `Primary` + * `Leave blank to keep the current key` */ - ["com.affine.settings.workspace.byok.placeholder.key-name"](): string; + ["com.affine.settings.workspace.byok.placeholder.keep-current-key"](): string; /** - * `Workspace fallback key` + * `Model ID` */ - ["com.affine.settings.workspace.byok.placeholder.description"](): string; + ["com.affine.settings.workspace.byok.placeholder.model-id"](): string; + /** + * `Search models…` + */ + ["com.affine.settings.workspace.byok.placeholder.search-models"](): string; + /** + * `Use a custom API-compatible endpoint` + */ + ["com.affine.settings.workspace.byok.endpoint.use-custom"](): string; + /** + * `Include an image generation request when testing (provider charges may apply)` + */ + ["com.affine.settings.workspace.byok.probe.include-image"](): string; + /** + * `Connection verified` + */ + ["com.affine.settings.workspace.byok.probe.verified"](): string; + /** + * `Connection failed` + */ + ["com.affine.settings.workspace.byok.probe.failed"](): string; + /** + * `Recommended` + */ + ["com.affine.settings.workspace.byok.model.recommended"](): string; + /** + * `This model has already been added.` + */ + ["com.affine.settings.workspace.byok.model.duplicate-id"](): string; + /** + * `Not tested` + */ + ["com.affine.settings.workspace.byok.model.status.not-tested"](): string; + /** + * `Verified` + */ + ["com.affine.settings.workspace.byok.model.status.verified"](): string; + /** + * `Failed` + */ + ["com.affine.settings.workspace.byok.model.status.failed"](): string; + /** + * `Partially verified · {{verified}} of {{total}}` + */ + ["com.affine.settings.workspace.byok.model.status.partially-verified"](options: Readonly<{ + verified: string; + total: string; + }>): string; + /** + * `Disabled` + */ + ["com.affine.settings.workspace.byok.model.status.disabled"](): string; + /** + * `Use this model for` + */ + ["com.affine.settings.workspace.byok.model.use-this-for"](): string; + /** + * `Chat & writing` + */ + ["com.affine.settings.workspace.byok.model.use.chat"](): string; + /** + * `Actions` + */ + ["com.affine.settings.workspace.byok.model.use.actions"](): string; + /** + * `Structured output` + */ + ["com.affine.settings.workspace.byok.model.use.structured"](): string; + /** + * `Image understanding` + */ + ["com.affine.settings.workspace.byok.model.use.vision"](): string; + /** + * `Image generation` + */ + ["com.affine.settings.workspace.byok.model.use.image"](): string; + /** + * `Transcription` + */ + ["com.affine.settings.workspace.byok.model.use.transcript"](): string; + /** + * `Workspace indexing` + */ + ["com.affine.settings.workspace.byok.model.use.embedding"](): string; + /** + * `Search reranking` + */ + ["com.affine.settings.workspace.byok.model.use.rerank"](): string; /** * `Add key` */ ["com.affine.settings.workspace.byok.action.add-key"](): string; /** - * `Test key` + * `Test connection` */ - ["com.affine.settings.workspace.byok.action.test-key"](): string; + ["com.affine.settings.workspace.byok.action.test-connection"](): string; + /** + * `Test` + */ + ["com.affine.settings.workspace.byok.action.test"](): string; + /** + * `Testing…` + */ + ["com.affine.settings.workspace.byok.action.testing"](): string; /** * `Cancel` */ ["com.affine.settings.workspace.byok.action.cancel"](): string; /** - * `Save key` + * `Connect` */ - ["com.affine.settings.workspace.byok.action.save-key"](): string; + ["com.affine.settings.workspace.byok.action.connect"](): string; + /** + * `Connecting…` + */ + ["com.affine.settings.workspace.byok.action.connecting"](): string; + /** + * `Save changes` + */ + ["com.affine.settings.workspace.byok.action.save-changes"](): string; + /** + * `Add model` + */ + ["com.affine.settings.workspace.byok.action.add-model"](): string; + /** + * `Add {{count}} models` + */ + ["com.affine.settings.workspace.byok.action.add-selected-models"](options: { + readonly count: string; + }): string; + /** + * `Save model` + */ + ["com.affine.settings.workspace.byok.action.save-model"](): string; + /** + * `Enable {{model}}` + */ + ["com.affine.settings.workspace.byok.action.enable-model"](options: { + readonly model: string; + }): string; + /** + * `Disable {{model}}` + */ + ["com.affine.settings.workspace.byok.action.disable-model"](options: { + readonly model: string; + }): string; + /** + * `Options for {{model}}` + */ + ["com.affine.settings.workspace.byok.action.model-options"](options: { + readonly model: string; + }): string; + /** + * `Move up` + */ + ["com.affine.settings.workspace.byok.action.move-up"](): string; + /** + * `Move down` + */ + ["com.affine.settings.workspace.byok.action.move-down"](): string; + /** + * `Remove` + */ + ["com.affine.settings.workspace.byok.action.remove"](): string; /** * `Clear all BYOK keys` */ @@ -6228,6 +6417,14 @@ export function useAFFiNEI18N(): { * `BYOK keys not cleared` */ ["com.affine.settings.workspace.byok.notify.clear-failed.title"](): string; + /** + * `BYOK settings changed` + */ + ["com.affine.settings.workspace.byok.notify.reload-required.title"](): string; + /** + * `Reload the settings and try again.` + */ + ["com.affine.settings.workspace.byok.notify.reload-required.message"](): string; /** * `Please try again.` */ diff --git a/packages/frontend/i18n/src/resources/de.json b/packages/frontend/i18n/src/resources/de.json index 331e61cd68..0c43fa27d2 100644 --- a/packages/frontend/i18n/src/resources/de.json +++ b/packages/frontend/i18n/src/resources/de.json @@ -1480,25 +1480,19 @@ "com.affine.settings.workspace": "Workspace", "com.affine.settings.workspace.description": "Hier kannst du die Informationen zum aktuellen Workspace anzeigen.", "com.affine.settings.workspace.byok.title-beta": "AI BYOK (Beta)", - "com.affine.settings.workspace.byok.title": "AI BYOK", "com.affine.settings.workspace.byok.loading": "Provider-Schlüssel werden geladen.", "com.affine.settings.workspace.byok.subtitle": "Benutze deine eigenen Provider-Schlüssel für diesen Workspace.", "com.affine.settings.workspace.byok.header": "Benutze die Workspace-Provider-Schlüssel vor der Verwendung des AFFiNE AI Plans.", "com.affine.settings.workspace.byok.locked.title": "Für BYOK ist die Pro-, Team- oder Believer-Version erforderlich", "com.affine.settings.workspace.byok.locked.description": "Upgrade diesen Workspace, um Provider-Schlüssel hinzuzufügen und AFFiNE AI über dein eigenes OpenAI-, Anthropic-, Gemini- oder FAL-Konto zu routen.", - "com.affine.settings.workspace.byok.notice.title": "Der AI-Plan bleibt verfügbar", - "com.affine.settings.workspace.byok.notice.description": "Zunächst werden lokale Schlüssel auf diesem Gerät geprüft. Anschließend folgen die Schlüssel des Workspace-Servers und schließlich die Routen des AFFiNE AI-Plans, sofern Kontingent verfügbar ist.", "com.affine.settings.workspace.byok.keys.title": "Provider-Schlüssel", "com.affine.settings.workspace.byok.keys.description": "Die Reihenfolge in der Liste bestimmt die Fallback-Reihenfolge innerhalb jeder Speichergruppe.", "com.affine.settings.workspace.byok.empty.title": "Keine Provider-Schlüssel", "com.affine.settings.workspace.byok.empty.description": "Füge einen Schlüssel hinzu, um die erste Route für diesen Workspace zu erstellen. Die Zeilen des Providers werden erst angezeigt, wenn ein Schlüssel vorhanden ist.", "com.affine.settings.workspace.byok.storage.local": "Lokal", "com.affine.settings.workspace.byok.storage.server": "Server", - "com.affine.settings.workspace.byok.storage.local-this-device": "Lokal (dieses Gerät)", - "com.affine.settings.workspace.byok.storage.local-desktop-only": "Lokal (nur Desktop)", "com.affine.settings.workspace.byok.status.disabled-after-failure": "Nach Fehler deaktiviert", "com.affine.settings.workspace.byok.status.key-verified": "Schlüssel verifiziert", - "com.affine.settings.workspace.byok.status.key-test-failed": "Schlüssel-Test fehlgeschlagen", "com.affine.settings.workspace.byok.capability.text": "Text", "com.affine.settings.workspace.byok.capability.image-input": "Bildeingabe", "com.affine.settings.workspace.byok.capability.actions": "Aktionen", @@ -1506,8 +1500,6 @@ "com.affine.settings.workspace.byok.capability.transcript": "Transkript", "com.affine.settings.workspace.byok.capability.indexing": "Indizierung", "com.affine.settings.workspace.byok.row.activity.failed": "fehlgeschlagen {{date}}", - "com.affine.settings.workspace.byok.row.activity.used": "verwendet {{date}}", - "com.affine.settings.workspace.byok.row.activity.used-today": "heute verwendet", "com.affine.settings.workspace.byok.row.activity.unused": "noch nicht verwendet", "com.affine.settings.workspace.byok.coverage.title": "Feature-Abdeckung", "com.affine.settings.workspace.byok.feature.chat.title": "Schreiben und Chatten", @@ -1525,23 +1517,15 @@ "com.affine.settings.workspace.byok.usage.title": "BYOK-Nutzung", "com.affine.settings.workspace.byok.usage.period": "Letzte 30 Tage", "com.affine.settings.workspace.byok.usage.tokens": "{{count}} Token", - "com.affine.settings.workspace.byok.modal.add-title": "Provider-Schlüssel hinzufügen", - "com.affine.settings.workspace.byok.modal.edit-title": "Provider-Schlüssel bearbeiten", - "com.affine.settings.workspace.byok.modal.description": "Den API-Key erneut eingeben und vor dem Speichern der Änderungen testen.", "com.affine.settings.workspace.byok.field.provider": "Provider", "com.affine.settings.workspace.byok.field.key-name": "Schlüsselname", "com.affine.settings.workspace.byok.field.description": "Beschreibung", - "com.affine.settings.workspace.byok.field.storage": "Schlüssel-Speicher", "com.affine.settings.workspace.byok.field.api-key": "API-Key", "com.affine.settings.workspace.byok.field.endpoint": "Endpunkt", "com.affine.settings.workspace.byok.endpoint.custom-disabled": "Benutzerdefinierte Endpunkte wurden vom Serveradministrator deaktiviert. Aktiviere in Self-hosted Admin die Einstellung copilot.byok.allowCustomEndpoint.", "com.affine.settings.workspace.byok.endpoint.private-disabled": "Für Endpunkte in privaten Netzwerken muss der Serveradministrator zusätzlich die Einstellung copilot.byok.allowPrivateEndpoint aktivieren.", - "com.affine.settings.workspace.byok.placeholder.key-name": "Primär", - "com.affine.settings.workspace.byok.placeholder.description": "Workspace-Fallback-Schlüssel", "com.affine.settings.workspace.byok.action.add-key": "Schlüssel hinzufügen", - "com.affine.settings.workspace.byok.action.test-key": "Schlüssel testen", "com.affine.settings.workspace.byok.action.cancel": "Abbrechen", - "com.affine.settings.workspace.byok.action.save-key": "Schlüssel speichern", "com.affine.settings.workspace.byok.action.clear-all": "Alle BYOK-Schlüssel löschen", "com.affine.settings.workspace.byok.action.reorder": "Umsortieren", "com.affine.settings.workspace.byok.action.edit": "Bearbeiten", diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 0d0c744f3f..7891e240f1 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1479,26 +1479,25 @@ "com.affine.settings.meetings.record.permission-modal.open-setting": "Open System Settings", "com.affine.settings.workspace": "Workspace", "com.affine.settings.workspace.description": "You can view current workspace's information here.", - "com.affine.settings.workspace.byok.title-beta": "AI BYOK (Beta)", - "com.affine.settings.workspace.byok.title": "AI BYOK", + "com.affine.settings.workspace.byok.title": "AI BYOK (Beta)", "com.affine.settings.workspace.byok.loading": "Loading provider keys.", "com.affine.settings.workspace.byok.subtitle": "Use your own provider keys for this workspace.", "com.affine.settings.workspace.byok.header": "Use workspace provider keys before AFFiNE AI plan routes.", "com.affine.settings.workspace.byok.locked.title": "BYOK requires Pro, Team, or Believer", "com.affine.settings.workspace.byok.locked.description": "Upgrade this workspace to add provider keys and route AFFiNE AI through your own OpenAI, Anthropic, Gemini, or FAL account.", - "com.affine.settings.workspace.byok.notice.title": "AI plan stays available", - "com.affine.settings.workspace.byok.notice.description": "Local keys on this device are tried first. Workspace server keys follow, then AFFiNE AI plan routes when quota is available.", "com.affine.settings.workspace.byok.keys.title": "Provider keys", "com.affine.settings.workspace.byok.keys.description": "List order controls fallback within each storage group.", "com.affine.settings.workspace.byok.empty.title": "No provider keys", "com.affine.settings.workspace.byok.empty.description": "Add a key to create the first route for this workspace. Provider rows are not shown until a key exists.", "com.affine.settings.workspace.byok.storage.local": "Local", "com.affine.settings.workspace.byok.storage.server": "Server", - "com.affine.settings.workspace.byok.storage.local-this-device": "Local (this device)", - "com.affine.settings.workspace.byok.storage.local-desktop-only": "Local (Desktop only)", + "com.affine.settings.workspace.byok.storage.server.description": "Available to workspace members.", + "com.affine.settings.workspace.byok.storage.local.description": "Kept in this desktop device’s secure storage.", + "com.affine.settings.workspace.byok.storage.local.desktop-only": "Available in the AFFiNE desktop app.", + "com.affine.settings.workspace.byok.storage.local.unavailable": "Secure local storage is not available on this device.", + "com.affine.settings.workspace.byok.storage.local.test-disclosure": "Testing sends this key to the workspace server for this request only; it is not stored there.", "com.affine.settings.workspace.byok.status.disabled-after-failure": "Disabled after failure", "com.affine.settings.workspace.byok.status.key-verified": "Key verified", - "com.affine.settings.workspace.byok.status.key-test-failed": "Key test failed", "com.affine.settings.workspace.byok.capability.text": "Text", "com.affine.settings.workspace.byok.capability.image-input": "Image input", "com.affine.settings.workspace.byok.capability.actions": "Actions", @@ -1506,8 +1505,6 @@ "com.affine.settings.workspace.byok.capability.transcript": "Transcript", "com.affine.settings.workspace.byok.capability.indexing": "Indexing", "com.affine.settings.workspace.byok.row.activity.failed": "failed {{date}}", - "com.affine.settings.workspace.byok.row.activity.used": "used {{date}}", - "com.affine.settings.workspace.byok.row.activity.used-today": "used today", "com.affine.settings.workspace.byok.row.activity.unused": "not used yet", "com.affine.settings.workspace.byok.coverage.title": "Feature coverage", "com.affine.settings.workspace.byok.feature.chat.title": "Writing and chat", @@ -1525,23 +1522,71 @@ "com.affine.settings.workspace.byok.usage.title": "BYOK usage", "com.affine.settings.workspace.byok.usage.period": "Last 30 days", "com.affine.settings.workspace.byok.usage.tokens": "{{count}} tokens", - "com.affine.settings.workspace.byok.modal.add-title": "Add provider key", - "com.affine.settings.workspace.byok.modal.edit-title": "Edit provider key", - "com.affine.settings.workspace.byok.modal.description": "Re-enter the API key and test it before saving changes.", + "com.affine.settings.workspace.byok.modal.connect-title": "Connect AI provider", + "com.affine.settings.workspace.byok.modal.manage-title": "Manage provider", + "com.affine.settings.workspace.byok.modal.connect-description": "Choose where the key is stored, then select the models AFFiNE may use.", + "com.affine.settings.workspace.byok.modal.add-model-title": "Add models", + "com.affine.settings.workspace.byok.modal.add-custom-model-title": "Add custom model", + "com.affine.settings.workspace.byok.modal.edit-model-title": "Edit model", + "com.affine.settings.workspace.byok.modal.catalog-model-description": "Choose one or more models this key may use.", + "com.affine.settings.workspace.byok.modal.custom-model-description": "Enter the endpoint model ID and choose its uses.", + "com.affine.settings.workspace.byok.section.connection": "Connection", + "com.affine.settings.workspace.byok.section.models": "Models", + "com.affine.settings.workspace.byok.section.advanced": "Advanced details", + "com.affine.settings.workspace.byok.models.description.selected": "Models this key may use.", + "com.affine.settings.workspace.byok.models.description.order": "Among compatible models, enabled models are tried from top to bottom.", + "com.affine.settings.workspace.byok.models.empty": "No models added yet.", + "com.affine.settings.workspace.byok.models.all-added": "All available models have been added.", + "com.affine.settings.workspace.byok.models.no-search-results": "No matching models.", "com.affine.settings.workspace.byok.field.provider": "Provider", "com.affine.settings.workspace.byok.field.key-name": "Key name", "com.affine.settings.workspace.byok.field.description": "Description", - "com.affine.settings.workspace.byok.field.storage": "Key storage", + "com.affine.settings.workspace.byok.field.provider-enabled": "Provider enabled", "com.affine.settings.workspace.byok.field.api-key": "API key", "com.affine.settings.workspace.byok.field.endpoint": "Endpoint", + "com.affine.settings.workspace.byok.field.model-id": "Model ID", "com.affine.settings.workspace.byok.endpoint.custom-disabled": "Custom endpoints are disabled by the server administrator. In Self-hosted Admin, enable copilot.byok.allowCustomEndpoint.", "com.affine.settings.workspace.byok.endpoint.private-disabled": "Private network endpoints additionally require the server administrator to enable copilot.byok.allowPrivateEndpoint.", - "com.affine.settings.workspace.byok.placeholder.key-name": "Primary", - "com.affine.settings.workspace.byok.placeholder.description": "Workspace fallback key", + "com.affine.settings.workspace.byok.placeholder.keep-current-key": "Leave blank to keep the current key", + "com.affine.settings.workspace.byok.placeholder.model-id": "Model ID", + "com.affine.settings.workspace.byok.placeholder.search-models": "Search models…", + "com.affine.settings.workspace.byok.endpoint.use-custom": "Use a custom API-compatible endpoint", + "com.affine.settings.workspace.byok.probe.include-image": "Include an image generation request when testing (provider charges may apply)", + "com.affine.settings.workspace.byok.probe.verified": "Connection verified", + "com.affine.settings.workspace.byok.probe.failed": "Connection failed", + "com.affine.settings.workspace.byok.model.recommended": "Recommended", + "com.affine.settings.workspace.byok.model.duplicate-id": "This model has already been added.", + "com.affine.settings.workspace.byok.model.status.not-tested": "Not tested", + "com.affine.settings.workspace.byok.model.status.verified": "Verified", + "com.affine.settings.workspace.byok.model.status.failed": "Failed", + "com.affine.settings.workspace.byok.model.status.partially-verified": "Partially verified · {{verified}} of {{total}}", + "com.affine.settings.workspace.byok.model.status.disabled": "Disabled", + "com.affine.settings.workspace.byok.model.use-this-for": "Use this model for", + "com.affine.settings.workspace.byok.model.use.chat": "Chat & writing", + "com.affine.settings.workspace.byok.model.use.actions": "Actions", + "com.affine.settings.workspace.byok.model.use.structured": "Structured output", + "com.affine.settings.workspace.byok.model.use.vision": "Image understanding", + "com.affine.settings.workspace.byok.model.use.image": "Image generation", + "com.affine.settings.workspace.byok.model.use.transcript": "Transcription", + "com.affine.settings.workspace.byok.model.use.embedding": "Workspace indexing", + "com.affine.settings.workspace.byok.model.use.rerank": "Search reranking", "com.affine.settings.workspace.byok.action.add-key": "Add key", - "com.affine.settings.workspace.byok.action.test-key": "Test key", + "com.affine.settings.workspace.byok.action.test-connection": "Test connection", + "com.affine.settings.workspace.byok.action.test": "Test", + "com.affine.settings.workspace.byok.action.testing": "Testing…", "com.affine.settings.workspace.byok.action.cancel": "Cancel", - "com.affine.settings.workspace.byok.action.save-key": "Save key", + "com.affine.settings.workspace.byok.action.connect": "Connect", + "com.affine.settings.workspace.byok.action.connecting": "Connecting…", + "com.affine.settings.workspace.byok.action.save-changes": "Save changes", + "com.affine.settings.workspace.byok.action.add-model": "Add model", + "com.affine.settings.workspace.byok.action.add-selected-models": "Add {{count}} models", + "com.affine.settings.workspace.byok.action.save-model": "Save model", + "com.affine.settings.workspace.byok.action.enable-model": "Enable {{model}}", + "com.affine.settings.workspace.byok.action.disable-model": "Disable {{model}}", + "com.affine.settings.workspace.byok.action.model-options": "Options for {{model}}", + "com.affine.settings.workspace.byok.action.move-up": "Move up", + "com.affine.settings.workspace.byok.action.move-down": "Move down", + "com.affine.settings.workspace.byok.action.remove": "Remove", "com.affine.settings.workspace.byok.action.clear-all": "Clear all BYOK keys", "com.affine.settings.workspace.byok.action.reorder": "Reorder", "com.affine.settings.workspace.byok.action.edit": "Edit", @@ -1554,6 +1599,8 @@ "com.affine.settings.workspace.byok.notify.delete-failed.title": "BYOK key not deleted", "com.affine.settings.workspace.byok.notify.reorder-failed.title": "BYOK keys not reordered", "com.affine.settings.workspace.byok.notify.clear-failed.title": "BYOK keys not cleared", + "com.affine.settings.workspace.byok.notify.reload-required.title": "BYOK settings changed", + "com.affine.settings.workspace.byok.notify.reload-required.message": "Reload the settings and try again.", "com.affine.settings.workspace.byok.notify.operation-failed.message": "Please try again.", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.title": "Cannot reorder across storage", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "Local keys and server keys keep separate fallback order.", diff --git a/packages/frontend/i18n/src/resources/kk.json b/packages/frontend/i18n/src/resources/kk.json index e6cc66992f..4ac0668cad 100644 --- a/packages/frontend/i18n/src/resources/kk.json +++ b/packages/frontend/i18n/src/resources/kk.json @@ -1439,25 +1439,19 @@ "com.affine.settings.workspace": "Жұмыс кеңістігі", "com.affine.settings.workspace.description": "Ағымдағы жұмыс кеңістігінің ақпаратын осы жерден көре аласың.", "com.affine.settings.workspace.byok.title-beta": "AI BYOK (Бета)", - "com.affine.settings.workspace.byok.title": "AI BYOK", "com.affine.settings.workspace.byok.loading": "Провайдер кілттері жүктелуде.", "com.affine.settings.workspace.byok.subtitle": "Бұл жұмыс кеңістігі үшін өз провайдер кілттеріңді пайдалан.", "com.affine.settings.workspace.byok.header": "AFFiNE AI жоспар бағыттарына дейін жұмыс кеңістігінің провайдер кілттерін пайдалан.", "com.affine.settings.workspace.byok.locked.title": "BYOK үшін Pro, Team немесе Believer қажет", "com.affine.settings.workspace.byok.locked.description": "Провайдер кілттерін қосу және AFFiNE AI-ды өзіңнің OpenAI, Anthropic, Gemini немесе FAL аккаунтың арқылы бағыттау үшін осы жұмыс кеңістігін жаңарт.", - "com.affine.settings.workspace.byok.notice.title": "AI жоспары қолжетімді болып қалады", - "com.affine.settings.workspace.byok.notice.description": "Алдымен осы құрылғыдағы жергілікті кілттер сыналады. Одан кейін жұмыс кеңістігінің сервер кілттері, сосын квота болған кезде AFFiNE AI жоспар бағыттары пайдаланылады.", "com.affine.settings.workspace.byok.keys.title": "Провайдер кілттері", "com.affine.settings.workspace.byok.keys.description": "Тізім реті әр сақтау тобы ішінде резервтік ретті басқарады.", "com.affine.settings.workspace.byok.empty.title": "Провайдер кілттері жоқ", "com.affine.settings.workspace.byok.empty.description": "Осы жұмыс кеңістігі үшін бірінші бағытты жасау үшін кілт қос. Кілт болмайынша провайдер жолдары көрсетілмейді.", "com.affine.settings.workspace.byok.storage.local": "Жергілікті", "com.affine.settings.workspace.byok.storage.server": "Сервер", - "com.affine.settings.workspace.byok.storage.local-this-device": "Жергілікті (осы құрылғыда)", - "com.affine.settings.workspace.byok.storage.local-desktop-only": "Жергілікті (тек десктопта)", "com.affine.settings.workspace.byok.status.disabled-after-failure": "Сәтсіздіктен кейін өшірілді", "com.affine.settings.workspace.byok.status.key-verified": "Кілт расталды", - "com.affine.settings.workspace.byok.status.key-test-failed": "Кілтті тексеру сәтсіз аяқталды", "com.affine.settings.workspace.byok.capability.text": "Мәтін", "com.affine.settings.workspace.byok.capability.image-input": "Сурет енгізу", "com.affine.settings.workspace.byok.capability.actions": "Әрекеттер", @@ -1465,8 +1459,6 @@ "com.affine.settings.workspace.byok.capability.transcript": "Транскрипт", "com.affine.settings.workspace.byok.capability.indexing": "Индекстеу", "com.affine.settings.workspace.byok.row.activity.failed": "{{date}} күні сәтсіз аяқталды", - "com.affine.settings.workspace.byok.row.activity.used": "{{date}} күні қолданылды", - "com.affine.settings.workspace.byok.row.activity.used-today": "бүгін қолданылды", "com.affine.settings.workspace.byok.row.activity.unused": "әлі қолданылмаған", "com.affine.settings.workspace.byok.coverage.title": "Функцияларды қамту", "com.affine.settings.workspace.byok.feature.chat.title": "Жазу және чат", @@ -1484,21 +1476,13 @@ "com.affine.settings.workspace.byok.usage.title": "BYOK қолданылуы", "com.affine.settings.workspace.byok.usage.period": "Соңғы 30 күн", "com.affine.settings.workspace.byok.usage.tokens": "{{count}} токен", - "com.affine.settings.workspace.byok.modal.add-title": "Провайдер кілтін қос", - "com.affine.settings.workspace.byok.modal.edit-title": "Провайдер кілтін өңде", - "com.affine.settings.workspace.byok.modal.description": "API кілтін қайта енгіз және өзгерістерді сақтамас бұрын оны тексер.", "com.affine.settings.workspace.byok.field.provider": "Провайдер", "com.affine.settings.workspace.byok.field.key-name": "Кілт атауы", "com.affine.settings.workspace.byok.field.description": "Сипаттама", - "com.affine.settings.workspace.byok.field.storage": "Кілт сақтау орны", "com.affine.settings.workspace.byok.field.api-key": "API кілті", "com.affine.settings.workspace.byok.field.endpoint": "Эндпойнт", - "com.affine.settings.workspace.byok.placeholder.key-name": "Негізгі", - "com.affine.settings.workspace.byok.placeholder.description": "Жұмыс кеңістігінің резервтік кілті", "com.affine.settings.workspace.byok.action.add-key": "Кілтті қос", - "com.affine.settings.workspace.byok.action.test-key": "Кілтті тексер", "com.affine.settings.workspace.byok.action.cancel": "Бас тарт", - "com.affine.settings.workspace.byok.action.save-key": "Кілтті сақта", "com.affine.settings.workspace.byok.action.clear-all": "Барлық BYOK кілттерін тазарт", "com.affine.settings.workspace.byok.action.reorder": "Ретін өзгерт", "com.affine.settings.workspace.byok.action.edit": "Өңде", diff --git a/packages/frontend/i18n/src/resources/tr.json b/packages/frontend/i18n/src/resources/tr.json index 22023d5b91..2526bc9450 100644 --- a/packages/frontend/i18n/src/resources/tr.json +++ b/packages/frontend/i18n/src/resources/tr.json @@ -1439,25 +1439,19 @@ "com.affine.settings.workspace": "Çalışma alanı", "com.affine.settings.workspace.description": "Mevcut çalışma alanının bilgilerini burada görüntüleyebilirsiniz.", "com.affine.settings.workspace.byok.title-beta": "AI BYOK (Beta)", - "com.affine.settings.workspace.byok.title": "AI BYOK", "com.affine.settings.workspace.byok.loading": "Sağlayıcı anahtarları yükleniyor.", "com.affine.settings.workspace.byok.subtitle": "Bu çalışma alanı için kendi sağlayıcı anahtarlarınızı kullanın.", "com.affine.settings.workspace.byok.header": "AFFiNE AI plan rotalarından önce çalışma alanı sağlayıcı anahtarlarını kullanın.", "com.affine.settings.workspace.byok.locked.title": "BYOK için Pro, Team veya Believer gerekir", "com.affine.settings.workspace.byok.locked.description": "Sağlayıcı anahtarları eklemek ve AFFiNE AI'yi kendi OpenAI, Anthropic, Gemini veya FAL hesabınız üzerinden yönlendirmek için bu çalışma alanını yükseltin.", - "com.affine.settings.workspace.byok.notice.title": "Yapay zeka planı kullanılabilir durumda kalacak", - "com.affine.settings.workspace.byok.notice.description": "Önce bu cihazdaki yerel anahtarlar denenir. Çalışma alanı sunucusu anahtarları takip eder ve ardından kota mevcut olduğunda AFFiNE AI rotaları planlar.", "com.affine.settings.workspace.byok.keys.title": "Sağlayıcı anahtarları", "com.affine.settings.workspace.byok.keys.description": "Her depolama grubunda yedek (fallback) sırasını liste sırası belirler.", "com.affine.settings.workspace.byok.empty.title": "Sağlayıcı anahtarı yok", "com.affine.settings.workspace.byok.empty.description": "Bu çalışma alanı için ilk rotayı oluşturmak üzere bir anahtar ekleyin. Sağlayıcı satırları bir anahtar mevcut olana kadar gösterilmez.", "com.affine.settings.workspace.byok.storage.local": "Yerel", "com.affine.settings.workspace.byok.storage.server": "Sunucu", - "com.affine.settings.workspace.byok.storage.local-this-device": "Yerel (bu cihaz)", - "com.affine.settings.workspace.byok.storage.local-desktop-only": "Yerel (Yalnızca masaüstü)", "com.affine.settings.workspace.byok.status.disabled-after-failure": "Arızadan sonra devre dışı bırakıldı", "com.affine.settings.workspace.byok.status.key-verified": "Anahtar doğrulandı", - "com.affine.settings.workspace.byok.status.key-test-failed": "Anahtar testi başarısız oldu", "com.affine.settings.workspace.byok.capability.text": "Metin", "com.affine.settings.workspace.byok.capability.image-input": "Görüntü girişi", "com.affine.settings.workspace.byok.capability.actions": "Eylemler", @@ -1465,8 +1459,6 @@ "com.affine.settings.workspace.byok.capability.transcript": "Deşifre metni", "com.affine.settings.workspace.byok.capability.indexing": "İndeksleme", "com.affine.settings.workspace.byok.row.activity.failed": "başarısız oldu {{date}}", - "com.affine.settings.workspace.byok.row.activity.used": "{{date}} kullanıldı", - "com.affine.settings.workspace.byok.row.activity.used-today": "Bugün kullanıldı", "com.affine.settings.workspace.byok.row.activity.unused": "henüz kullanılmadı", "com.affine.settings.workspace.byok.coverage.title": "Özellik kapsamı", "com.affine.settings.workspace.byok.feature.chat.title": "Yazma ve sohbet", @@ -1484,21 +1476,13 @@ "com.affine.settings.workspace.byok.usage.title": "BYOK kullanımı", "com.affine.settings.workspace.byok.usage.period": "Son 30 gün", "com.affine.settings.workspace.byok.usage.tokens": "{{count}} jeton", - "com.affine.settings.workspace.byok.modal.add-title": "Sağlayıcı anahtarı ekle", - "com.affine.settings.workspace.byok.modal.edit-title": "Sağlayıcı anahtarını düzenle", - "com.affine.settings.workspace.byok.modal.description": "Değişiklikleri kaydetmeden önce API anahtarını tekrar girin ve test edin.", "com.affine.settings.workspace.byok.field.provider": "Sağlayıcı", "com.affine.settings.workspace.byok.field.key-name": "Anahtar adı", "com.affine.settings.workspace.byok.field.description": "Tanım", - "com.affine.settings.workspace.byok.field.storage": "Anahtar saklama", "com.affine.settings.workspace.byok.field.api-key": "API anahtarı", "com.affine.settings.workspace.byok.field.endpoint": "Uç nokta", - "com.affine.settings.workspace.byok.placeholder.key-name": "Birincil", - "com.affine.settings.workspace.byok.placeholder.description": "Çalışma alanı yedek anahtarı", "com.affine.settings.workspace.byok.action.add-key": "Anahtar ekle", - "com.affine.settings.workspace.byok.action.test-key": "Test anahtarı", "com.affine.settings.workspace.byok.action.cancel": "İptal", - "com.affine.settings.workspace.byok.action.save-key": "Anahtarı kaydet", "com.affine.settings.workspace.byok.action.clear-all": "Tüm BYOK anahtarlarını temizle", "com.affine.settings.workspace.byok.action.reorder": "Yeniden sırala", "com.affine.settings.workspace.byok.action.edit": "Düzenle", diff --git a/packages/frontend/i18n/src/resources/ur.json b/packages/frontend/i18n/src/resources/ur.json index b334048a0f..7725de02c7 100644 --- a/packages/frontend/i18n/src/resources/ur.json +++ b/packages/frontend/i18n/src/resources/ur.json @@ -1735,8 +1735,6 @@ "com.affine.settings.workspace.byok.action.delete": "حذف کریں۔", "com.affine.settings.workspace.byok.action.edit": "ترمیم کریں۔", "com.affine.settings.workspace.byok.action.reorder": "دوبارہ ترتیب دیں۔", - "com.affine.settings.workspace.byok.action.save-key": "کلید محفوظ کریں۔", - "com.affine.settings.workspace.byok.action.test-key": "ٹیسٹ کلید", "com.affine.settings.workspace.byok.capability.actions": "اعمال", "com.affine.settings.workspace.byok.capability.image-generate": "تصویر بنائیں", "com.affine.settings.workspace.byok.capability.image-input": "امیج ان پٹ", @@ -1761,18 +1759,12 @@ "com.affine.settings.workspace.byok.field.endpoint": "اختتامی نقطہ", "com.affine.settings.workspace.byok.field.key-name": "کلیدی نام", "com.affine.settings.workspace.byok.field.provider": "فراہم کرنے والا", - "com.affine.settings.workspace.byok.field.storage": "کلیدی ذخیرہ", "com.affine.settings.workspace.byok.header": "AFFiNE AI پلان کے راستوں سے پہلے ورک اسپیس فراہم کنندہ کیز استعمال کریں۔", "com.affine.settings.workspace.byok.keys.description": "فہرست آرڈر کنٹرولز فال بیک ہر اسٹوریج گروپ کے اندر۔", "com.affine.settings.workspace.byok.keys.title": "فراہم کنندہ کی چابیاں", "com.affine.settings.workspace.byok.loading": "فراہم کنندہ کی چابیاں لوڈ ہو رہی ہیں۔", "com.affine.settings.workspace.byok.locked.description": "فراہم کنندہ کیز شامل کرنے کے لیے اس ورک اسپیس کو اپ گریڈ کریں اور AFFiNE AI کو اپنے OpenAI، Anthropic، Gemini، یا FAL اکاؤنٹ کے ذریعے روٹ کریں۔", "com.affine.settings.workspace.byok.locked.title": "BYOK کو پرو، ٹیم، یا مومن درکار ہے۔", - "com.affine.settings.workspace.byok.modal.add-title": "فراہم کنندہ کلید شامل کریں۔", - "com.affine.settings.workspace.byok.modal.description": "API کلید دوبارہ درج کریں اور تبدیلیاں محفوظ کرنے سے پہلے اس کی جانچ کریں۔", - "com.affine.settings.workspace.byok.modal.edit-title": "فراہم کنندہ کلید میں ترمیم کریں۔", - "com.affine.settings.workspace.byok.notice.description": "اس آلہ پر مقامی کلیدیں پہلے آزمائی جاتی ہیں۔ ورک اسپیس سرور کیز فالو کرتی ہیں، پھر کوٹہ دستیاب ہونے پر AFFiNE AI پلان روٹس۔", - "com.affine.settings.workspace.byok.notice.title": "AI پلان دستیاب رہتا ہے۔", "com.affine.settings.workspace.byok.notify.clear-failed.title": "BYOK کیز صاف نہیں ہوئیں", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "لوکل کیز اور سرور کیز الگ الگ فال بیک آرڈر رکھتی ہیں۔", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.title": "پورے اسٹوریج میں دوبارہ ترتیب نہیں دی جا سکتی", @@ -1784,21 +1776,13 @@ "com.affine.settings.workspace.byok.notify.reorder-failed.title": "BYOK کیز دوبارہ ترتیب نہیں دی گئیں۔", "com.affine.settings.workspace.byok.notify.save-failed.title": "BYOK کلید محفوظ نہیں ہے۔", "com.affine.settings.workspace.byok.notify.test-failed.title": "کلیدی ٹیسٹ ناکام ہو گیا۔", - "com.affine.settings.workspace.byok.placeholder.description": "ورک اسپیس فال بیک کلید", - "com.affine.settings.workspace.byok.placeholder.key-name": "پرائمری", "com.affine.settings.workspace.byok.row.activity.failed": "ناکام {{date}}", "com.affine.settings.workspace.byok.row.activity.unused": "ابھی تک استعمال نہیں کیا", - "com.affine.settings.workspace.byok.row.activity.used": "استعمال شدہ {{date}}", - "com.affine.settings.workspace.byok.row.activity.used-today": "آج استعمال کیا جاتا ہے", "com.affine.settings.workspace.byok.status.disabled-after-failure": "ناکامی کے بعد معذور", - "com.affine.settings.workspace.byok.status.key-test-failed": "کلیدی ٹیسٹ ناکام ہو گیا۔", "com.affine.settings.workspace.byok.status.key-verified": "کلید کی تصدیق ہو گئی۔", "com.affine.settings.workspace.byok.storage.local": "مقامی", - "com.affine.settings.workspace.byok.storage.local-desktop-only": "مقامی (صرف ڈیسک ٹاپ)", - "com.affine.settings.workspace.byok.storage.local-this-device": "مقامی (یہ آلہ)", "com.affine.settings.workspace.byok.storage.server": "سرور", "com.affine.settings.workspace.byok.subtitle": "اس ورک اسپیس کے لیے اپنی فراہم کنندہ کیز استعمال کریں۔", - "com.affine.settings.workspace.byok.title": "AI BYOK", "com.affine.settings.workspace.byok.title-beta": "AI BYOK (بیٹا)", "com.affine.settings.workspace.byok.usage.period": "آخری 30 دن", "com.affine.settings.workspace.byok.usage.title": "BYOK کا استعمال", diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index 68b4282e97..35e5670cb0 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -1478,25 +1478,19 @@ "com.affine.settings.workspace": "工作区", "com.affine.settings.workspace.description": "您可以在此处自定义您的工作区。", "com.affine.settings.workspace.byok.title-beta": "AI BYOK(Beta)", - "com.affine.settings.workspace.byok.title": "AI BYOK", "com.affine.settings.workspace.byok.loading": "正在加载服务商密钥。", "com.affine.settings.workspace.byok.subtitle": "为此工作区使用您自己的服务商密钥。", "com.affine.settings.workspace.byok.header": "优先使用工作区服务商密钥,再回退到 AFFiNE AI 套餐通道。", "com.affine.settings.workspace.byok.locked.title": "BYOK 需要 Pro、Team 或 Believer", "com.affine.settings.workspace.byok.locked.description": "升级此工作区后,可添加服务商密钥,并通过您自己的 OpenAI、Anthropic、Gemini 或 FAL 账号路由 AFFiNE AI。", - "com.affine.settings.workspace.byok.notice.title": "AI 套餐仍可用", - "com.affine.settings.workspace.byok.notice.description": "会先尝试此设备上的本地密钥,然后使用工作区服务端密钥,最后在额度可用时回退到 AFFiNE AI 套餐通道。", "com.affine.settings.workspace.byok.keys.title": "服务商密钥", "com.affine.settings.workspace.byok.keys.description": "列表顺序控制每个存储分组内的回退顺序。", "com.affine.settings.workspace.byok.empty.title": "暂无服务商密钥", "com.affine.settings.workspace.byok.empty.description": "添加密钥后,将为此工作区创建第一条路由。创建前不会显示服务商行。", "com.affine.settings.workspace.byok.storage.local": "本地", "com.affine.settings.workspace.byok.storage.server": "服务端", - "com.affine.settings.workspace.byok.storage.local-this-device": "本地(此设备)", - "com.affine.settings.workspace.byok.storage.local-desktop-only": "本地(仅桌面端)", "com.affine.settings.workspace.byok.status.disabled-after-failure": "失败后已禁用", "com.affine.settings.workspace.byok.status.key-verified": "密钥已验证", - "com.affine.settings.workspace.byok.status.key-test-failed": "密钥测试失败", "com.affine.settings.workspace.byok.capability.text": "文本", "com.affine.settings.workspace.byok.capability.image-input": "图片输入", "com.affine.settings.workspace.byok.capability.actions": "动作", @@ -1504,8 +1498,6 @@ "com.affine.settings.workspace.byok.capability.transcript": "转录", "com.affine.settings.workspace.byok.capability.indexing": "索引", "com.affine.settings.workspace.byok.row.activity.failed": "{{date}} 失败", - "com.affine.settings.workspace.byok.row.activity.used": "{{date}} 使用", - "com.affine.settings.workspace.byok.row.activity.used-today": "今天使用", "com.affine.settings.workspace.byok.row.activity.unused": "尚未使用", "com.affine.settings.workspace.byok.coverage.title": "功能覆盖", "com.affine.settings.workspace.byok.feature.chat.title": "写作与聊天", @@ -1523,23 +1515,17 @@ "com.affine.settings.workspace.byok.usage.title": "BYOK 用量", "com.affine.settings.workspace.byok.usage.period": "最近 30 天", "com.affine.settings.workspace.byok.usage.tokens": "{{count}} tokens", - "com.affine.settings.workspace.byok.modal.add-title": "添加服务商密钥", - "com.affine.settings.workspace.byok.modal.edit-title": "编辑服务商密钥", - "com.affine.settings.workspace.byok.modal.description": "重新输入 API key 并测试通过后才能保存更改。", "com.affine.settings.workspace.byok.field.provider": "服务商", "com.affine.settings.workspace.byok.field.key-name": "密钥名称", "com.affine.settings.workspace.byok.field.description": "描述", - "com.affine.settings.workspace.byok.field.storage": "密钥存储", "com.affine.settings.workspace.byok.field.api-key": "API key", "com.affine.settings.workspace.byok.field.endpoint": "Endpoint", "com.affine.settings.workspace.byok.endpoint.custom-disabled": "服务器管理员已禁用自定义端点。请在自托管管理后台启用 copilot.byok.allowCustomEndpoint。", "com.affine.settings.workspace.byok.endpoint.private-disabled": "私有网络端点还需要服务器管理员启用 copilot.byok.allowPrivateEndpoint。", - "com.affine.settings.workspace.byok.placeholder.key-name": "Primary", - "com.affine.settings.workspace.byok.placeholder.description": "工作区回退密钥", "com.affine.settings.workspace.byok.action.add-key": "添加密钥", - "com.affine.settings.workspace.byok.action.test-key": "测试密钥", + "com.affine.settings.workspace.byok.action.test": "测试", + "com.affine.settings.workspace.byok.action.testing": "测试中…", "com.affine.settings.workspace.byok.action.cancel": "取消", - "com.affine.settings.workspace.byok.action.save-key": "保存密钥", "com.affine.settings.workspace.byok.action.clear-all": "清空所有 BYOK 密钥", "com.affine.settings.workspace.byok.action.reorder": "重新排序", "com.affine.settings.workspace.byok.action.edit": "编辑", @@ -1552,6 +1538,8 @@ "com.affine.settings.workspace.byok.notify.delete-failed.title": "BYOK 密钥未删除", "com.affine.settings.workspace.byok.notify.reorder-failed.title": "BYOK 密钥未重新排序", "com.affine.settings.workspace.byok.notify.clear-failed.title": "BYOK 密钥未清空", + "com.affine.settings.workspace.byok.notify.reload-required.title": "BYOK 设置已变化", + "com.affine.settings.workspace.byok.notify.reload-required.message": "请重新加载设置后再试。", "com.affine.settings.workspace.byok.notify.operation-failed.message": "请稍后重试。", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.title": "不能跨存储排序", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "本地密钥和服务端密钥会保留各自独立的回退顺序。", diff --git a/tests/affine-cloud-copilot/playwright.config.ts b/tests/affine-cloud-copilot/playwright.config.ts index cb36b41687..15d3b47002 100644 --- a/tests/affine-cloud-copilot/playwright.config.ts +++ b/tests/affine-cloud-copilot/playwright.config.ts @@ -29,9 +29,9 @@ const config: PlaywrightTestConfig = { webServer: [ { command: 'yarn run -T affine dev -p @affine/web', - stdout: 'ignore', - stderr: 'ignore', - timeout: 120 * 1000, + stdout: 'pipe', + stderr: 'pipe', + timeout: 240 * 1000, reuseExistingServer: !process.env.CI, env: { COVERAGE: process.env.COVERAGE || 'false', @@ -40,10 +40,10 @@ const config: PlaywrightTestConfig = { }, { command: 'yarn run -T affine dev -p @affine/server', - timeout: 120 * 1000, + timeout: 240 * 1000, reuseExistingServer: !process.env.CI, - stdout: 'ignore', - stderr: 'ignore', + stdout: 'pipe', + stderr: 'pipe', env: { DATABASE_URL: process.env.DATABASE_URL ??