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

fix #14287
fix #15359
fix #15424

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

* **New Features**
* Redesigned workspace AI provider settings with connection testing,
storage options, model selection, capability management, ordering, and
custom endpoints.
* AI chat model choices now adapt to the selected workspace and
conversation route.
  * Added support for image-based AI requests.
* **Bug Fixes**
  * Improved handling of unavailable or outdated model selections.
* App configuration updates now reject overlapping paths and load
deterministically.
* **Tests**
* Expanded coverage for provider models, AI chat scoping, image
requests, and configuration validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-08-05 19:26:19 +08:00
committed by GitHub
parent 965f4590ff
commit 543667d9b3
57 changed files with 2951 additions and 1472 deletions

4
Cargo.lock generated
View File

@@ -4753,9 +4753,9 @@ dependencies = [
[[package]] [[package]]
name = "llm_adapter" name = "llm_adapter"
version = "0.2.14" version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4086072a8f69a2a119e187367844d542f133f1bdf96ebdf495b49b83cf4a05" checksum = "5cfb4eab8636c2e3ac87a221630f6b458e1a7ffb32e3701576840871b98d1bc6"
dependencies = [ dependencies = [
"base64", "base64",
"jsonschema", "jsonschema",

View File

@@ -135,9 +135,25 @@ fn estimated_message_bytes(message: &PromptMessageContract) -> usize {
let mut size = MESSAGE_FRAMING_BYTES let mut size = MESSAGE_FRAMING_BYTES
.saturating_add(message.role.len()) .saturating_add(message.role.len())
.saturating_add(message.content.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 [ for value in [
message.attachments.as_ref().map(serde_json::to_vec), message.params.as_ref().map(|params| {
message.params.as_ref().map(serde_json::to_vec), 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), message.response_format.as_ref().map(serde_json::to_vec),
] ]
.into_iter() .into_iter()
@@ -148,6 +164,32 @@ fn estimated_message_bytes(message: &PromptMessageContract) -> usize {
size 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::<Map<_, _>>();
Value::Object(metadata)
}
fn select_history_turns( fn select_history_turns(
fixed_messages: &[PromptMessageContract], fixed_messages: &[PromptMessageContract],
history: &[PromptMessageContract], history: &[PromptMessageContract],
@@ -266,6 +308,64 @@ mod tests {
let emoji = estimated_message_bytes(&message("user", "😀😀😀")); let emoji = estimated_message_bytes(&message("user", "😀😀😀"));
assert!(ascii < cjk); assert!(ascii < cjk);
assert!(cjk < emoji); 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(&params_small),
estimated_message_bytes(&params_large)
);
} }
#[test] #[test]

View File

@@ -6,6 +6,16 @@ mod ffi;
mod prompt_catalog; mod prompt_catalog;
pub(crate) mod route; 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::{ pub use self::core::{
capability::llm_match_model_capabilities, capability::llm_match_model_capabilities,
model_registry::{llm_match_model_registry, llm_resolve_model_registry_variant}, 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}, 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)] #[napi_derive::napi(catch_unwind)]
pub fn llm_get_byok_catalog() -> ByokCatalogOutput { pub fn llm_get_byok_catalog() -> ByokCatalogOutput {
byok_catalog() byok_catalog()
@@ -44,6 +44,8 @@ pub use route::{
CopilotAccessProjection, CopilotExecuteInput, CopilotManagedTier, CopilotRouteCheckInput, CopilotTargetOverrideInput, CopilotAccessProjection, CopilotExecuteInput, CopilotManagedTier, CopilotRouteCheckInput, CopilotTargetOverrideInput,
}; };
pub(crate) use self::core::contracts::LlmImageRequestContract;
pub(crate) fn invalid_arg(message: impl Into<String>) -> napi::Error { pub(crate) fn invalid_arg(message: impl Into<String>) -> napi::Error {
napi::Error::new(napi::Status::InvalidArg, message.into()) napi::Error::new(napi::Status::InvalidArg, message.into())
} }

View File

@@ -432,9 +432,10 @@ fn backend_error_kind(error: &BackendError) -> &'static str {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
use llm_adapter::target::BackendEndpoint; use llm_adapter::target::BackendEndpoint;
use super::*;
#[test] #[test]
fn connection_probe_errors_are_low_information() { fn connection_probe_errors_are_low_information() {
let (url, headers) = probe_request("openai", &ByokEndpoint::ProviderDefault, "secret".to_string()); let (url, headers) = probe_request("openai", &ByokEndpoint::ProviderDefault, "secret".to_string());

View File

@@ -22,6 +22,7 @@ use zeroize::Zeroizing;
use super::{COPILOT_REQUEST_TIMEOUT, RuntimeError, RuntimeResult, context}; use super::{COPILOT_REQUEST_TIMEOUT, RuntimeError, RuntimeResult, context};
use crate::{ use crate::{
llm::{ llm::{
LlmImageRequestContract,
byok::{ByokEndpoint, CredentialEnvelopeKey}, byok::{ByokEndpoint, CredentialEnvelopeKey},
route::{ route::{
AuthorizedProfileRef, AuthorizedTargetRef, CatalogSlot, CredentialRef, RouteOperation, with_request_requirements, 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::Embedding => ExecutableRequest::Embedding(parse_request(request)?),
RouteOperation::Rerank => ExecutableRequest::Rerank(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::<LlmImageRequestContract>(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); let (needs_tools, attachment_kinds, attachment_sources) = request_requirements(&executable);
Ok(( Ok((
@@ -405,3 +413,46 @@ impl From<BackendError> for RuntimeError {
RuntimeError::invalid_state(error.to_string()) 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"));
}
}

View File

@@ -8,15 +8,14 @@ use std::{
time::Duration, 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 gcp_auth::TokenProvider;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::sync::OnceCell; use tokio::sync::OnceCell;
use zeroize::Zeroizing; 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 super::{BackendRuntime, RuntimeError, RuntimeResult, to_napi_error};
use crate::{ use crate::{
llm::{ llm::{

View File

@@ -330,7 +330,10 @@ fn default_mail_class_mapping() -> BTreeMap<String, String> {
} }
async fn load_app_config_overrides_from_db(pool: &PgPool) -> RuntimeResult<serde_json::Value> { async fn load_app_config_overrides_from_db(pool: &PgPool) -> RuntimeResult<serde_json::Value> {
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, Ok(rows) => rows,
Err(sqlx::Error::Database(err)) if err.code().as_deref() == Some("42P01") => { Err(sqlx::Error::Database(err)) if err.code().as_deref() == Some("42P01") => {
return Ok(serde_json::Value::Object(Map::new())); return Ok(serde_json::Value::Object(Map::new()));
@@ -360,6 +363,8 @@ where
S: AsRef<str>, S: AsRef<str>,
{ {
let mut root = Map::new(); let mut root = Map::new();
let mut rows = rows.into_iter().collect::<Vec<_>>();
rows.sort_by(|(left, _), (right, _)| left.as_ref().cmp(right.as_ref()));
for (path, value) in rows { for (path, value) in rows {
insert_flat_override(&mut root, path.as_ref(), value); insert_flat_override(&mut root, path.as_ref(), value);
} }
@@ -512,6 +517,22 @@ mod tests {
assert_eq!(copilot.providers.profiles[0].id, "managed-openai"); 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] #[test]
fn database_config_only_replaces_an_active_private_key_explicitly() { fn database_config_only_replaces_an_active_private_key_explicitly() {
let active = BackendRuntimeConfig { let active = BackendRuntimeConfig {

View File

@@ -144,6 +144,8 @@ test('image request builder receives only serializable request options', async t
runtime.streamImageArtifacts({}, [{ role: 'user', content: 'draw' }], { runtime.streamImageArtifacts({}, [{ role: 'user', content: 'draw' }], {
quality: 'high', quality: 'high',
seed: 42, seed: 42,
modelName: 'stabilityai/stable-diffusion-xl-base-1.0',
loras: [{ path: 'https://example.com/sketch.safetensors', scale: 1 }],
signal: controller.signal, signal: controller.signal,
user: 'user-1', user: 'user-1',
}) })
@@ -158,6 +160,13 @@ test('image request builder receives only serializable request options', async t
outputFormat: 'webp', outputFormat: 'webp',
seed: 42, seed: 42,
}, },
providerOptions: {
provider: 'fal',
options: {
model_name: 'stabilityai/stable-diffusion-xl-base-1.0',
loras: [{ path: 'https://example.com/sketch.safetensors', scale: 1 }],
},
},
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { PrismaClient } from '@prisma/client';
import test from 'ava'; import test from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -14,6 +15,7 @@ const module = await createModule({
const service = module.get(ServerService); const service = module.get(ServerService);
const user = await module.create(Mockers.User); const user = await module.create(Mockers.User);
const models = module.get(Models); const models = module.get(Models);
const db = module.get(PrismaClient);
test.afterEach(async () => { test.afterEach(async () => {
Sinon.reset(); Sinon.reset();
@@ -111,6 +113,40 @@ test('should revalidate config', async t => {
t.is(service.getConfig().server.externalUrl, newValue); 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 => { test('should emit config changed event', async t => {
const newUrl = faker.internet.url(); const newUrl = faker.internet.url();

View File

@@ -9,11 +9,34 @@ export class AppConfigModel extends BaseModel {
async load(excludedKeys: string[] = []) { async load(excludedKeys: string[] = []) {
return this.db.appConfig.findMany({ return this.db.appConfig.findMany({
where: excludedKeys.length ? { id: { notIn: excludedKeys } } : undefined, where: excludedKeys.length ? { id: { notIn: excludedKeys } } : undefined,
orderBy: { id: 'asc' },
}); });
} }
@Transactional() @Transactional()
async save(user: string, updates: Array<{ key: string; value: any }>) { 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( return await Promise.allSettled(
updates.map(async update => { updates.map(async update => {
return this.db.appConfig.upsert({ return this.db.appConfig.upsert({

View File

@@ -398,13 +398,13 @@ export class CapabilityRuntime {
_filter?: ProviderFilter, _filter?: ProviderFilter,
slot = 'image.generate' slot = 'image.generate'
): AsyncIterableIterator<NativeImageArtifact> { ): AsyncIterableIterator<NativeImageArtifact> {
const { quality, seed } = options; const { quality, seed, modelName, loras } = options;
const result = (await this.execute( const result = (await this.execute(
slot, slot,
buildLlmImageRequestFromMessages({ buildLlmImageRequestFromMessages({
model: 'route-selected', model: 'route-selected',
messages: preparePromptMessagesForNativeRequest(messages, true), messages: preparePromptMessagesForNativeRequest(messages, true),
options: { quality, seed }, options: { quality, seed, modelName, loras },
}), }),
cond, cond,
options options

View File

@@ -367,7 +367,6 @@ declare global {
// TODO(@Peng): should be refactored to get rid of implement details (like messages, action, role, etc.) // TODO(@Peng): should be refactored to get rid of implement details (like messages, action, role, etc.)
interface AIHistory { interface AIHistory {
sessionId: string; sessionId: string;
tokens: number;
action: string | null; action: string | null;
createdAt: string; createdAt: string;
messages: { messages: {

View File

@@ -2,9 +2,9 @@ import './ai-chat-composer-tip';
import type { import type {
AIDraftService, AIDraftService,
AIModelService,
AIToolsConfigService, AIToolsConfigService,
} from '@affine/core/modules/ai-button'; } from '@affine/core/modules/ai-button';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
import type { import type {
ServerService, ServerService,
SubscriptionService, SubscriptionService,
@@ -128,15 +128,15 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService; accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false }) @property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService; accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false }) @property({ attribute: false })
accessor subscriptionService!: SubscriptionService; accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false }) @property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>; accessor onAISubscribe!: () => Promise<void>;
@@ -183,9 +183,9 @@ export class AIChatComposer extends SignalWatcher(
.affineFeatureFlagService=${this.affineFeatureFlagService} .affineFeatureFlagService=${this.affineFeatureFlagService}
.aiDraftService=${this.aiDraftService} .aiDraftService=${this.aiDraftService}
.aiToolsConfigService=${this.aiToolsConfigService} .aiToolsConfigService=${this.aiToolsConfigService}
.aiModelService=${this.aiModelService}
.notificationService=${this.notificationService} .notificationService=${this.notificationService}
.subscriptionService=${this.subscriptionService} .subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe} .onAISubscribe=${this.onAISubscribe}
.portalContainer=${this.portalContainer} .portalContainer=${this.portalContainer}
.onChatSuccess=${this.onChatSuccess} .onChatSuccess=${this.onChatSuccess}

View File

@@ -1,9 +1,9 @@
import type { import type {
AIDraftService, AIDraftService,
AIModelService,
AIToolsConfigService, AIToolsConfigService,
} from '@affine/core/modules/ai-button'; } from '@affine/core/modules/ai-button';
import type { AIDraftState } from '@affine/core/modules/ai-button/services/ai-draft'; 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 { import type {
ServerService, ServerService,
SubscriptionService, SubscriptionService,
@@ -404,8 +404,8 @@ export class AIChatContent extends SignalWatcher(
.notificationService=${this.notificationService} .notificationService=${this.notificationService}
.aiDraftService=${this.aiDraftService} .aiDraftService=${this.aiDraftService}
.aiToolsConfigService=${this.aiToolsConfigService} .aiToolsConfigService=${this.aiToolsConfigService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService} .aiModelService=${this.aiModelService}
.subscriptionService=${this.subscriptionService}
.onAISubscribe=${this.onAISubscribe} .onAISubscribe=${this.onAISubscribe}
.trackOptions=${{ .trackOptions=${{
where: 'chat-panel', where: 'chat-panel',

View File

@@ -1,8 +1,8 @@
import type { import type {
AIDraftService, AIDraftService,
AIModelService,
AIToolsConfigService, AIToolsConfigService,
} from '@affine/core/modules/ai-button'; } from '@affine/core/modules/ai-button';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
import type { import type {
ServerService, ServerService,
SubscriptionService, SubscriptionService,
@@ -399,6 +399,9 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService; accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false }) @property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService; accessor affineFeatureFlagService!: FeatureFlagService;
@@ -408,9 +411,6 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor subscriptionService!: SubscriptionService; accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false }) @property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>; accessor onAISubscribe!: () => Promise<void>;
@@ -483,6 +483,12 @@ export class AIChatInput extends SignalWatcher(
window.addEventListener('dragend', this._resetDragState); window.addEventListener('dragend', this._resetDragState);
} }
protected override updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has('workspaceId')) {
this.aiModelService.setScope(this.workspaceId, 'Chat With AFFiNE AI');
}
}
protected override firstUpdated(changedProperties: PropertyValues): void { protected override firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties); super.firstUpdated(changedProperties);
if (this.aiDraftService) { if (this.aiDraftService) {
@@ -631,9 +637,9 @@ export class AIChatInput extends SignalWatcher(
.onExtendedThinkingChange=${this._toggleReasoning} .onExtendedThinkingChange=${this._toggleReasoning}
.serverService=${this.serverService} .serverService=${this.serverService}
.toolsConfigService=${this.aiToolsConfigService} .toolsConfigService=${this.aiToolsConfigService}
.aiModelService=${this.aiModelService}
.notificationService=${this.notificationService} .notificationService=${this.notificationService}
.subscriptionService=${this.subscriptionService} .subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe} .onAISubscribe=${this.onAISubscribe}
></chat-input-preference> ></chat-input-preference>
${status === 'transmitting' || status === 'loading' ${status === 'transmitting' || status === 'loading'
@@ -858,7 +864,7 @@ export class AIChatInput extends SignalWatcher(
control: this.trackOptions?.control, control: this.trackOptions?.control,
reasoning: this._isReasoningActive, reasoning: this._isReasoningActive,
toolsConfig: this.aiToolsConfigService.config.value, toolsConfig: this.aiToolsConfigService.config.value,
modelId: this.aiModelService.modelId.value, routeTargetId: this.aiModelService.modelId.value,
userInfo: { userInfo: {
userId: userInfo?.id, userId: userInfo?.id,
userName: userInfo?.name, userName: userInfo?.name,

View File

@@ -1,14 +1,5 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button'; import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; 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 { import {
menu, menu,
popMenu, popMenu,
@@ -70,10 +61,10 @@ export class ChatInputPreference extends SignalWatcher(
min-width: 220px; min-width: 220px;
} }
.ai-active-model-name { .ai-active-model-name {
font-size: 14px;
color: ${unsafeCSSVarV2('text/secondary')};
line-height: 22px;
margin-left: 40px; margin-left: 40px;
color: ${unsafeCSSVarV2('text/secondary')};
font-size: 14px;
line-height: 22px;
} }
.ai-model-prefix { .ai-model-prefix {
width: 20px; width: 20px;
@@ -82,21 +73,21 @@ export class ChatInputPreference extends SignalWatcher(
.ai-model-prefix svg { .ai-model-prefix svg {
color: ${unsafeCSSVarV2('icon/activated')}; color: ${unsafeCSSVarV2('icon/activated')};
} }
.ai-model-postfix {
width: 20px;
height: 20px;
}
.ai-model-postfix svg:hover { .ai-model-postfix svg:hover {
color: ${unsafeCSSVarV2('icon/activated')}; color: ${unsafeCSSVarV2('icon/activated')};
} }
.ai-model-version { .ai-model-version {
font-size: 12px;
color: ${unsafeCSSVarV2('text/tertiary')};
line-height: 20px;
margin-right: 40px; 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 --------- // --------- extended thinking props start ---------
@property({ attribute: false }) @property({ attribute: false })
accessor extendedThinking: boolean = false; accessor extendedThinking: boolean = false;
@@ -107,90 +98,91 @@ export class ChatInputPreference extends SignalWatcher(
| undefined; | undefined;
// --------- extended thinking props end --------- // --------- extended thinking props end ---------
@property({ attribute: false })
accessor serverService!: ServerService;
@property({ attribute: false }) @property({ attribute: false })
accessor toolsConfigService!: AIToolsConfigService; accessor toolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor subscriptionService!: SubscriptionService;
@property({ attribute: false }) @property({ attribute: false })
accessor aiModelService!: AIModelService; accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false }) @property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>; accessor onAISubscribe!: () => Promise<void>;
model = computed(() => { private readonly model = computed(() =>
const modelId = this.aiModelService.modelId.value; this.aiModelService.models.value.find(
const activeModel = this.aiModelService.models.value.find( model => model.id === this.aiModelService.modelId.value
model => model.id === modelId )
); );
const defaultModel = this.aiModelService.models.value.find(
model => model.isDefault
);
return activeModel || defaultModel;
});
openPreference(e: Event) { openPreference(e: Event) {
const element = e.currentTarget; const element = e.currentTarget;
if (!(element instanceof HTMLElement)) return; if (!(element instanceof HTMLElement)) return;
const modelItems = []; const preferenceItems = [];
const searchItems = []; const searchItems = [];
// model switch if (this.aiModelService.models.value.length) {
modelItems.push( preferenceItems.push(
menu.subMenu({ menu.subMenu({
name: 'Model', name: 'Model',
prefix: AiOutlineIcon(), prefix: AiOutlineIcon(),
middleware: modelSubMenuMiddleware, middleware: modelSubMenuMiddleware,
postfix: html` postfix: html`
<span class="ai-active-model-name"> ${this.model.value?.name} </span> <span class="ai-active-model-name">
`, ${this.model.value?.name ?? 'Auto'}
options: { </span>
items: this.aiModelService.models.value.map(model => { `,
const isSelected = model.id === this.model.value?.id; options: {
const isSelfHosted = items: [
this.serverService.server.config$.value?.type === menu.action({
ServerDeploymentType.Selfhosted; name: 'Auto',
const status = prefix: html`
this.subscriptionService.subscription.ai$.value?.status; <div class="ai-model-prefix">
const isSubscribed = status === SubscriptionStatus.Active; ${this.aiModelService.modelId.value
return menu.action({ ? undefined
name: model.category, : DoneIcon()}
info: html` </div>
<span class="ai-model-version">${model.version}</span> `,
`, select: () => this.aiModelService.resetModel(),
prefix: html` }),
<div class="ai-model-prefix"> ...this.aiModelService.models.value.map(model =>
${isSelected ? DoneIcon() : undefined} menu.action({
</div> name: model.category,
`, info: html`
postfix: html` <span class="ai-model-version">${model.version}</span>
<div class="ai-model-postfix" @click=${this.onAISubscribe}> `,
${model.isPro && !isSubscribed ? LockIcon() : undefined} prefix: html`
</div> <div class="ai-model-prefix">
`, ${model.id === this.aiModelService.modelId.value
select: () => { ? DoneIcon()
if (model.isPro && !isSelfHosted && !isSubscribed) { : undefined}
this.notificationService.toast( </div>
`Pro models require an AFFiNE AI subscription.` `,
); postfix: html`
return; <div class="ai-model-postfix">
} ${model.available ? undefined : LockIcon()}
this.aiModelService.setModel(model.id); </div>
}, `,
}); 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({ menu.toggleSwitch({
name: 'Extended Thinking', name: 'Extended Thinking',
prefix: ThinkingIcon(), prefix: ThinkingIcon(),
@@ -220,7 +212,7 @@ export class ChatInputPreference extends SignalWatcher(
options: { options: {
items: [ items: [
menu.group({ menu.group({
items: [...modelItems], items: [...preferenceItems],
}), }),
menu.group({ menu.group({
items: [...searchItems], items: [...searchItems],
@@ -238,7 +230,7 @@ export class ChatInputPreference extends SignalWatcher(
class="chat-input-preference-trigger" class="chat-input-preference-trigger"
> >
<span class="chat-input-preference-trigger-label"> <span class="chat-input-preference-trigger-label">
${this.model.value?.category} ${this.model.value?.category ?? 'Auto'}
</span> </span>
<span class="chat-input-preference-trigger-icon"> <span class="chat-input-preference-trigger-icon">
${ArrowDownSmallIcon()} ${ArrowDownSmallIcon()}

View File

@@ -1,5 +1,7 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button'; import type {
import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; AIModelService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { import type {
ServerService, ServerService,
SubscriptionService, SubscriptionService,
@@ -185,10 +187,10 @@ export class PlaygroundChat extends SignalWatcher(
accessor aiToolsConfigService!: AIToolsConfigService; accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false }) @property({ attribute: false })
accessor subscriptionService!: SubscriptionService; accessor aiModelService!: AIModelService;
@property({ attribute: false }) @property({ attribute: false })
accessor aiModelService!: AIModelService; accessor subscriptionService!: SubscriptionService;
@property({ attribute: false }) @property({ attribute: false })
accessor onAISubscribe: (() => Promise<void>) | undefined; accessor onAISubscribe: (() => Promise<void>) | undefined;

View File

@@ -1,5 +1,7 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button'; import type {
import type { AIModelService } from '@affine/core/modules/ai-button/services/models'; AIModelService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { import type {
ServerService, ServerService,
SubscriptionService, SubscriptionService,
@@ -103,15 +105,15 @@ export class PlaygroundContent extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService; accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false }) @property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService; accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false }) @property({ attribute: false })
accessor subscriptionService!: SubscriptionService; accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@state() @state()
accessor sessions: CopilotChatHistoryFragment[] = []; accessor sessions: CopilotChatHistoryFragment[] = [];
@@ -380,10 +382,10 @@ export class PlaygroundContent extends SignalWatcher(
.affineThemeService=${this.affineThemeService} .affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService} .notificationService=${this.notificationService}
.aiToolsConfigService=${this.aiToolsConfigService} .aiToolsConfigService=${this.aiToolsConfigService}
.aiModelService=${this.aiModelService}
.affineWorkspaceDialogService=${this .affineWorkspaceDialogService=${this
.affineWorkspaceDialogService} .affineWorkspaceDialogService}
.subscriptionService=${this.subscriptionService} .subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.addChat=${this.addChat} .addChat=${this.addChat}
></playground-chat> ></playground-chat>
</div> </div>

View File

@@ -1,8 +1,8 @@
import type { import type {
AIDraftService, AIDraftService,
AIModelService,
AIToolsConfigService, AIToolsConfigService,
} from '@affine/core/modules/ai-button'; } from '@affine/core/modules/ai-button';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
import type { import type {
ServerService, ServerService,
SubscriptionService, SubscriptionService,
@@ -584,6 +584,7 @@ export class AIChatBlockPeekView extends LitElement {
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService} .affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.notificationService=${notificationService} .notificationService=${notificationService}
.aiToolsConfigService=${this.aiToolsConfigService} .aiToolsConfigService=${this.aiToolsConfigService}
.aiModelService=${this.aiModelService}
.affineFeatureFlagService=${this.affineFeatureFlagService} .affineFeatureFlagService=${this.affineFeatureFlagService}
.onChatSuccess=${this._onChatSuccess} .onChatSuccess=${this._onChatSuccess}
.trackOptions=${{ .trackOptions=${{
@@ -594,7 +595,6 @@ export class AIChatBlockPeekView extends LitElement {
.reasoningConfig=${this.reasoningConfig} .reasoningConfig=${this.reasoningConfig}
.serverService=${this.serverService} .serverService=${this.serverService}
.subscriptionService=${this.subscriptionService} .subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe} .onAISubscribe=${this.onAISubscribe}
></ai-chat-composer> ></ai-chat-composer>
</div> `; </div> `;
@@ -681,8 +681,8 @@ export const AIChatBlockPeekViewTemplate = (
affineWorkspaceDialogService: WorkspaceDialogService, affineWorkspaceDialogService: WorkspaceDialogService,
aiDraftService: AIDraftService, aiDraftService: AIDraftService,
aiToolsConfigService: AIToolsConfigService, aiToolsConfigService: AIToolsConfigService,
subscriptionService: SubscriptionService,
aiModelService: AIModelService, aiModelService: AIModelService,
subscriptionService: SubscriptionService,
onAISubscribe: (() => Promise<void>) | undefined onAISubscribe: (() => Promise<void>) | undefined
) => { ) => {
return html`<ai-chat-block-peek-view return html`<ai-chat-block-peek-view
@@ -696,8 +696,8 @@ export const AIChatBlockPeekViewTemplate = (
.affineWorkspaceDialogService=${affineWorkspaceDialogService} .affineWorkspaceDialogService=${affineWorkspaceDialogService}
.aiDraftService=${aiDraftService} .aiDraftService=${aiDraftService}
.aiToolsConfigService=${aiToolsConfigService} .aiToolsConfigService=${aiToolsConfigService}
.subscriptionService=${subscriptionService}
.aiModelService=${aiModelService} .aiModelService=${aiModelService}
.subscriptionService=${subscriptionService}
.onAISubscribe=${onAISubscribe} .onAISubscribe=${onAISubscribe}
></ai-chat-block-peek-view>`; ></ai-chat-block-peek-view>`;
}; };

View File

@@ -18,7 +18,7 @@ export type AIChatSendOptions = {
control?: BlockSuitePresets.TrackerControl; control?: BlockSuitePresets.TrackerControl;
reasoning?: boolean; reasoning?: boolean;
toolsConfig?: unknown; toolsConfig?: unknown;
modelId?: string; routeTargetId?: string;
userInfo?: { userInfo?: {
userId?: string; userId?: string;
userName?: string; userName?: string;
@@ -45,7 +45,7 @@ export type AIChatAction =
| { type: 'clearError' } | { type: 'clearError' }
| { type: 'setComposerText'; text: string } | { type: 'setComposerText'; text: string }
| { type: 'setReasoning'; reasoning: boolean } | { type: 'setReasoning'; reasoning: boolean }
| { type: 'setModel'; modelId?: string } | { type: 'setRouteTarget'; routeTargetId?: string }
| { type: 'addAttachment'; attachment: string | Blob | File } | { type: 'addAttachment'; attachment: string | Blob | File }
| { type: 'removeAttachment'; index: number } | { type: 'removeAttachment'; index: number }
| { type: 'addContextItem'; item: AIChatContextItem } | { type: 'addContextItem'; item: AIChatContextItem }

View File

@@ -34,8 +34,6 @@ function session(
parentSessionId: null, parentSessionId: null,
promptName: 'Chat With AFFiNE AI', promptName: 'Chat With AFFiNE AI',
action: null, action: null,
optionalModels: null,
tokens: 0,
...overrides, ...overrides,
} as CopilotChatHistoryFragment; } as CopilotChatHistoryFragment;
} }

View File

@@ -152,8 +152,8 @@ export class AIChatRuntime {
case 'setReasoning': case 'setReasoning':
this.updateComposer({ reasoning: action.reasoning }); this.updateComposer({ reasoning: action.reasoning });
return; return;
case 'setModel': case 'setRouteTarget':
this.updateComposer({ modelId: action.modelId }); this.updateComposer({ routeTargetId: action.routeTargetId });
return; return;
case 'addAttachment': case 'addAttachment':
this.updateComposer({ this.updateComposer({
@@ -388,7 +388,8 @@ export class AIChatRuntime {
contextId: this.snapshot.composer.context.contextId, contextId: this.snapshot.composer.context.contextId,
reasoning: options.reasoning ?? this.snapshot.composer.reasoning, reasoning: options.reasoning ?? this.snapshot.composer.reasoning,
toolsConfig: options.toolsConfig ?? this.snapshot.composer.toolsConfig, toolsConfig: options.toolsConfig ?? this.snapshot.composer.toolsConfig,
modelId: options.modelId ?? this.snapshot.composer.modelId, routeTargetId:
options.routeTargetId ?? this.snapshot.composer.routeTargetId,
isRootSession: options.isRootSession, isRootSession: options.isRootSession,
where: options.where, where: options.where,
control: options.control, control: options.control,

View File

@@ -136,7 +136,7 @@ export type AIChatComposerState = {
context: AIChatContextState; context: AIChatContextState;
reasoning: boolean; reasoning: boolean;
toolsConfig?: AIToolsConfig; toolsConfig?: AIToolsConfig;
modelId?: string; routeTargetId?: string;
}; };
export type AIChatNavigationRequest = { export type AIChatNavigationRequest = {

View File

@@ -67,9 +67,8 @@ export async function createWorkspaceByokLocalLease(
provider: gqlProvider, provider: gqlProvider,
name: provider.name, name: provider.name,
description: provider.description ?? null, description: provider.description ?? null,
apiKey: provider.apiKey, credential: provider.credential,
endpoint: provider.endpoint ?? null, definition: provider.definition,
sortOrder: provider.sortOrder ?? 0,
enabled: provider.enabled ?? true, enabled: provider.enabled ?? true,
}, },
] ]

View File

@@ -467,7 +467,9 @@ export class CopilotClient {
sessionId, sessionId,
messageId, messageId,
reasoning, reasoning,
profileId,
modelId, modelId,
routeTargetId,
toolsConfig, toolsConfig,
actionId, actionId,
actionVersion, actionVersion,
@@ -478,7 +480,9 @@ export class CopilotClient {
sessionId: string; sessionId: string;
messageId?: string; messageId?: string;
reasoning?: boolean; reasoning?: boolean;
profileId?: string;
modelId?: string; modelId?: string;
routeTargetId?: string;
toolsConfig?: AIToolsConfig; toolsConfig?: AIToolsConfig;
actionId?: string; actionId?: string;
actionVersion?: string; actionVersion?: string;
@@ -495,7 +499,9 @@ export class CopilotClient {
const queryString = this.paramsToQueryString({ const queryString = this.paramsToQueryString({
messageId, messageId,
reasoning, reasoning,
profileId,
modelId, modelId,
routeTargetId,
toolsConfig, toolsConfig,
actionId, actionId,
actionVersion, actionVersion,

View File

@@ -24,7 +24,9 @@ export type TextToTextOptions = {
runId?: string; runId?: string;
isRootSession?: boolean; isRootSession?: boolean;
reasoning?: boolean; reasoning?: boolean;
profileId?: string;
modelId?: string; modelId?: string;
routeTargetId?: string;
toolsConfig?: AIToolsConfig; toolsConfig?: AIToolsConfig;
}; };
@@ -127,7 +129,9 @@ export function textToText({
actionVersion, actionVersion,
runId, runId,
reasoning, reasoning,
profileId,
modelId, modelId,
routeTargetId,
toolsConfig, toolsConfig,
}: TextToTextOptions) { }: TextToTextOptions) {
let messageId: string | undefined; let messageId: string | undefined;
@@ -161,7 +165,9 @@ export function textToText({
sessionId, sessionId,
messageId, messageId,
reasoning, reasoning,
profileId,
modelId, modelId,
routeTargetId,
toolsConfig, toolsConfig,
actionId, actionId,
actionVersion, actionVersion,
@@ -229,7 +235,9 @@ export function textToText({
sessionId, sessionId,
messageId, messageId,
reasoning, reasoning,
profileId,
modelId, modelId,
routeTargetId,
toolsConfig, toolsConfig,
actionId, actionId,
actionVersion, actionVersion,

View File

@@ -23,9 +23,13 @@ const electronApis = vi.hoisted(() => ({
Array<{ Array<{
provider: string; provider: string;
name: string; name: string;
apiKey: string; credential: string;
definition: {
version: number;
endpoint: { kind: string; url?: string | null };
models: unknown[];
};
description?: string | null; description?: string | null;
endpoint?: string | null;
sortOrder?: number | null; sortOrder?: number | null;
enabled?: boolean | null; enabled?: boolean | null;
}> }>
@@ -117,7 +121,12 @@ describe('runtime request transport BYOK local lease handling', () => {
{ {
provider: 'openai', provider: 'openai',
name: 'OpenAI', name: 'OpenAI',
apiKey: 'sk-local', credential: 'sk-local',
definition: {
version: 1,
endpoint: { kind: 'provider_default' },
models: [{ modelId: 'model-1', capabilities: [] }],
},
}, },
]), ]),
}; };

View File

@@ -1,12 +1,12 @@
import { Button, Modal, notify } from '@affine/component'; import { Button, Input, Modal, notify } from '@affine/component';
import { import {
ByokKeyStorage,
ByokProvider, ByokProvider,
testWorkspaceByokConfigMutation as testByokMutation, createWorkspaceByokProfileMutation,
upsertWorkspaceByokConfigMutation as upsertByokMutation, probeWorkspaceByokDraftMutation,
replaceWorkspaceByokProfileMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { logByokError } from './errors'; import { logByokError } from './errors';
import * as styles from './index.css'; import * as styles from './index.css';
@@ -18,13 +18,16 @@ import {
shouldShowEndpoint, shouldShowEndpoint,
storageLabel, storageLabel,
} from './metadata'; } from './metadata';
import type { import { ModelSelector } from './model-selector';
ByokKey, import {
ByokSettings, catalogModels,
ByokStorage, defaultModels,
ByokTestResult, type ModelDeclaration,
GqlFn, modelUseCases,
} from './types'; probeChecks,
} from './model-utils';
import type { ByokDefinition, ByokKey, ByokSettings, GqlFn } from './types';
import { ByokStorage } from './types';
export const AddKeyModal = ({ export const AddKeyModal = ({
workspaceId, workspaceId,
@@ -59,101 +62,133 @@ export const AddKeyModal = ({
const [provider, setProvider] = useState<ByokProvider>(ByokProvider.openai); const [provider, setProvider] = useState<ByokProvider>(ByokProvider.openai);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [storage, setStorage] = useState<ByokStorage>(ByokKeyStorage.server); const [profileEnabled, setProfileEnabled] = useState(true);
const [storage, setStorage] = useState<ByokStorage>(ByokStorage.server);
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [customEndpoint, setCustomEndpoint] = useState(false);
const [endpoint, setEndpoint] = useState(''); const [endpoint, setEndpoint] = useState('');
const [testResult, setTestResult] = useState<ByokTestResult | null>(null); const [models, setModels] = useState<ModelDeclaration[]>([]);
const [testing, setTesting] = useState(false); const [testStatus, setTestStatus] = useState<'passed' | 'failed' | null>(
const canTestStoredConfig = null
storage === ByokKeyStorage.server && );
editingKey?.storage === ByokKeyStorage.server && const [includeImageProbe, setIncludeImageProbe] = useState(false);
editingKey.provider === provider; const [busy, setBusy] = useState(false);
const canTest = !!apiKey || canTestStoredConfig; const busyRef = useRef(false);
const localStorageUnavailable = !localStorageSupported || !canAddLocalKey;
const localStorageDisabled = !!editingKey || localStorageUnavailable;
const showCustomEndpoint = shouldShowEndpoint(
isSelfHosted,
settings.customEndpointSupported
);
const endpointHint = endpointHintKey( const endpointHint = endpointHintKey(
settings.customEndpointSupported, settings.customEndpointSupported,
settings.privateEndpointSupported settings.privateEndpointSupported
); );
const providerCatalog = useMemo(
() => catalogModels(settings, provider),
[provider, settings]
);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) return;
return; const nextProvider = editingKey?.provider ?? ByokProvider.openai;
} setProvider(nextProvider);
setProvider(editingKey?.provider ?? ByokProvider.openai); setName(editingKey?.name ?? providerLabels[nextProvider]);
setName(editingKey?.name ?? '');
setDescription(editingKey?.description ?? ''); setDescription(editingKey?.description ?? '');
setProfileEnabled(editingKey?.enabled ?? true);
setStorage( setStorage(
editingKey?.storage ?? editingKey?.storage ??
(canAddServerKey ? ByokKeyStorage.server : ByokKeyStorage.local) (canAddServerKey ? ByokStorage.server : ByokStorage.local)
); );
setApiKey(''); setApiKey('');
setEndpoint(editingKey?.endpoint ?? ''); setEndpoint(editingKey?.definition.endpoint.url ?? '');
setTestResult(null); setCustomEndpoint(editingKey?.definition.endpoint.kind === 'custom');
}, [canAddServerKey, editingKey, open]); setModels(
editingKey?.definition.models ?? defaultModels(settings, nextProvider)
);
setTestStatus(null);
setIncludeImageProbe(false);
}, [canAddServerKey, editingKey, open, settings]);
const testKey = useCallback(async () => { const definition = useMemo<ByokDefinition>(
if (!gql) { () => ({
return; version: editingKey?.definition.version ?? 1,
} endpoint: customEndpoint
setTesting(true); ? { kind: 'custom', url: endpoint }
try { : { kind: 'provider_default', url: null },
const result = await gql({ models,
query: testByokMutation, }),
variables: { [customEndpoint, editingKey?.definition.version, endpoint, models]
input: { );
workspaceId,
provider, const invalidateTest = () => setTestStatus(null);
storage, const runProbe = useCallback(async () => {
apiKey: apiKey || null, if (!gql) return false;
endpoint: endpoint || null, const canReuseServerCredential =
configId: canTestStoredConfig ? editingKey.id : null, 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 const probe = result.probeWorkspaceByokDraft;
| undefined; const verifiedChecks = new Set(
setTestResult(nextResult ?? null); probe.models.flatMap(model =>
if (nextResult && !nextResult.ok) { model.checks
notify.error({ .filter(check => check.status.kind === 'verified')
title: byokT(t, 'notify.test-failed.title'), .map(check => `${model.modelId}\0${check.operation}`)
message: nextResult.message, )
}); );
} const passed =
} finally { checks.length > 0 &&
setTesting(false); probe.connection.kind === 'verified' &&
} checks.every(check =>
verifiedChecks.has(`${check.modelId}\0${check.operation}`)
);
setTestStatus(passed ? 'passed' : 'failed');
return passed;
}, [ }, [
apiKey, apiKey,
canTestStoredConfig, definition,
editingKey, editingKey,
endpoint,
gql, gql,
includeImageProbe,
models,
provider, provider,
storage,
t,
workspaceId, workspaceId,
]); ]);
const save = useCallback(async () => { const persist = useCallback(async () => {
if (!testResult?.ok || !gql) { if (!gql) return;
return; if (storage === ByokStorage.local) {
}
if (storage === ByokKeyStorage.local) {
const saved = await upsertLocalKey(workspaceId, { const saved = await upsertLocalKey(workspaceId, {
id: id:
editingKey?.storage === ByokKeyStorage.local editingKey?.storage === ByokStorage.local
? editingKey.id ? editingKey.id
: crypto.randomUUID(), : crypto.randomUUID(),
provider, provider,
name, name,
description, description,
apiKey, credential: apiKey,
endpoint: endpoint || null, definition,
sortOrder: sortOrder:
editingKey?.storage === ByokKeyStorage.local editingKey?.storage === ByokStorage.local
? editingKey.sortOrder ? editingKey.sortOrder
: localKeys.length, : localKeys.length,
enabled: true, enabled: profileEnabled,
}); });
if (!saved) { if (!saved) {
notify.error({ notify.error({
@@ -163,189 +198,359 @@ export const AddKeyModal = ({
return; return;
} }
setLocalKeys(await readLocalKeys(workspaceId)); 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({ 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: { variables: {
input: { input: {
workspaceId, workspaceId,
id:
editingKey?.storage === ByokKeyStorage.server
? editingKey.id
: null,
provider, provider,
name, name,
description, description: description || null,
storage, credential: apiKey,
apiKey: apiKey || null, definition,
endpoint: endpoint || null, enabled: profileEnabled,
enabled: true,
}, },
}, },
}); });
await onSaved(); await onSaved();
} }
onOpenChange(false); onOpenChange(false);
setApiKey('');
setTestResult(null);
}, [ }, [
apiKey, apiKey,
definition,
description, description,
editingKey, editingKey,
endpoint,
gql, gql,
localKeys, localKeys.length,
name, name,
onOpenChange, onOpenChange,
onSaved, onSaved,
provider, provider,
profileEnabled,
setLocalKeys, setLocalKeys,
storage, storage,
t, t,
testResult?.ok,
workspaceId, 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 ( return (
<Modal <Modal
width={520} width={640}
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={ title={byokT(
editingKey ? byokT(t, 'modal.edit-title') : byokT(t, 'modal.add-title') t,
} editingKey ? 'modal.manage-title' : 'modal.connect-title'
description={byokT(t, 'modal.description')} )}
description={byokT(t, 'modal.connect-description')}
> >
<div className={styles.form}> <div className={styles.form}>
<label className={styles.field}> <div className={styles.formSection}>
<span className={styles.label}>{byokT(t, 'field.provider')}</span> <div className={styles.sectionTitle}>
<select {byokT(t, 'section.connection')}
className={styles.input} </div>
value={provider} <label className={styles.field}>
onChange={event => { <span className={styles.label}>{byokT(t, 'field.provider')}</span>
setProvider(event.target.value as ByokProvider); <select
setTestResult(null);
}}
>
{settings.allowedProviders.map(provider => (
<option key={provider} value={provider}>
{providerLabels[provider]}
</option>
))}
</select>
</label>
<label className={styles.field}>
<span className={styles.label}>{byokT(t, 'field.key-name')}</span>
<input
className={styles.input}
value={name}
onChange={event => setName(event.target.value)}
placeholder={byokT(t, 'placeholder.key-name')}
/>
</label>
<label className={styles.field}>
<span className={styles.label}>{byokT(t, 'field.description')}</span>
<input
className={styles.input}
value={description}
onChange={event => setDescription(event.target.value)}
placeholder={byokT(t, 'placeholder.description')}
/>
</label>
<label className={styles.field}>
<span className={styles.label}>{byokT(t, 'field.storage')}</span>
<select
className={styles.input}
value={storage}
disabled={!!editingKey}
onChange={event => {
setStorage(event.target.value as ByokStorage);
setTestResult(null);
}}
>
<option value={ByokKeyStorage.server} disabled={!canAddServerKey}>
{storageLabel(t, ByokKeyStorage.server)}
</option>
<option
value={ByokKeyStorage.local}
disabled={!localStorageSupported || !canAddLocalKey}
>
{canAddLocalKey
? byokT(t, 'storage.local-this-device')
: byokT(t, 'storage.local-desktop-only')}
</option>
</select>
</label>
<label className={styles.field}>
<span className={styles.label}>{byokT(t, 'field.api-key')}</span>
<input
className={styles.input}
value={apiKey}
onChange={event => {
setApiKey(event.target.value);
setTestResult(null);
}}
type="password"
/>
</label>{' '}
{shouldShowEndpoint(isSelfHosted, settings.customEndpointSupported) ? (
<label className={styles.endpointField}>
<span className={styles.label}>{byokT(t, 'field.endpoint')}</span>
<input
className={styles.input} className={styles.input}
value={endpoint} value={provider}
disabled={!settings.customEndpointSupported} disabled={!!editingKey}
onChange={event => { onChange={event => {
setEndpoint(event.target.value); const next = event.target.value as ByokProvider;
setTestResult(null); setProvider(next);
setName(providerLabels[next]);
setModels(defaultModels(settings, next));
invalidateTest();
}} }}
placeholder="https://api.example.com/v1" >
{settings.allowedProviders.map(item => (
<option key={item} value={item}>
{providerLabels[item]}
</option>
))}
</select>
</label>
<div className={styles.storageOptions}>
<label
className={styles.storageOption}
data-disabled={!canAddServerKey}
>
<input
className={styles.storageRadio}
type="radio"
name="byok-storage"
checked={storage === ByokStorage.server}
disabled={!!editingKey || !canAddServerKey}
onChange={() => setStorage(ByokStorage.server)}
/>
<span className={styles.storageCopy}>
<strong>{storageLabel(t, ByokStorage.server)}</strong>
<small className={styles.storageDescription}>
{byokT(t, 'storage.server.description')}
</small>
</span>
</label>
<label
className={styles.storageOption}
data-disabled={localStorageUnavailable}
>
<input
className={styles.storageRadio}
type="radio"
name="byok-storage"
checked={storage === ByokStorage.local}
disabled={localStorageDisabled}
onChange={() => setStorage(ByokStorage.local)}
/>
<span className={styles.storageCopy}>
<strong>{storageLabel(t, ByokStorage.local)}</strong>
<small className={styles.storageDescription}>
{!BUILD_CONFIG.isElectron
? byokT(t, 'storage.local.desktop-only')
: !localStorageSupported
? byokT(t, 'storage.local.unavailable')
: byokT(t, 'storage.local.description')}
</small>
</span>
</label>
</div>
<label className={styles.field}>
<span className={styles.label}>{byokT(t, 'field.api-key')}</span>
<Input
size="large"
value={apiKey}
onChange={value => {
setApiKey(value);
invalidateTest();
}}
type="password"
placeholder={
editingKey?.storage === ByokStorage.server
? byokT(t, 'placeholder.keep-current-key')
: ''
}
/> />
{endpointHint ? ( {storage === ByokStorage.local ? (
<span className={styles.fieldHint}>{byokT(t, endpointHint)}</span> <span className={styles.fieldHint}>
{byokT(t, 'storage.local.test-disclosure')}
</span>
) : null} ) : null}
</label> </label>
{showCustomEndpoint ? (
<>
<label className={styles.checkboxRow}>
<input
type="checkbox"
checked={customEndpoint}
disabled={!settings.customEndpointSupported}
onChange={event => {
setCustomEndpoint(event.target.checked);
if (event.target.checked && !editingKey) setModels([]);
if (!event.target.checked)
setModels(defaultModels(settings, provider));
invalidateTest();
}}
/>
{byokT(t, 'endpoint.use-custom')}
</label>
{!settings.customEndpointSupported && endpointHint ? (
<span className={styles.fieldHint}>
{byokT(t, endpointHint)}
</span>
) : null}
{customEndpoint ? (
<label className={styles.endpointField}>
<span className={styles.label}>
{byokT(t, 'field.endpoint')}
</span>
<Input
size="large"
value={endpoint}
onChange={value => {
setEndpoint(value);
invalidateTest();
}}
placeholder="https://api.example.com/v1"
/>
{endpointHint ? (
<span className={styles.fieldHint}>
{byokT(t, endpointHint)}
</span>
) : null}
</label>
) : null}
</>
) : null}
</div>
<div className={styles.formSection}>
<div className={styles.sectionHeading}>
<div>
<div className={styles.sectionTitle}>
{byokT(t, 'section.models')}
</div>
<div className={styles.description}>
{byokT(t, 'models.description.selected')}
</div>
</div>
</div>
<ModelSelector
customEndpoint={customEndpoint}
catalog={providerCatalog}
models={models}
validation={editingKey?.validation}
onChange={models => {
setModels(models);
invalidateTest();
}}
/>
</div>
<details className={styles.advanced}>
<summary>{byokT(t, 'section.advanced')}</summary>
<div className={styles.advancedFields}>
<label className={styles.checkboxRow}>
<input
type="checkbox"
checked={profileEnabled}
onChange={event => setProfileEnabled(event.target.checked)}
/>
{byokT(t, 'field.provider-enabled')}
</label>
<label className={styles.field}>
<span className={styles.label}>{byokT(t, 'field.key-name')}</span>
<Input size="large" value={name} onChange={setName} />
</label>
<label className={styles.field}>
<span className={styles.label}>
{byokT(t, 'field.description')}
</span>
<Input
size="large"
value={description}
onChange={setDescription}
/>
</label>
</div>
</details>
{models.some(model => modelUseCases(model).includes('image')) ? (
<label className={styles.checkboxRow}>
<input
type="checkbox"
checked={includeImageProbe}
onChange={event => {
setIncludeImageProbe(event.target.checked);
invalidateTest();
}}
/>
{byokT(t, 'probe.include-image')}
</label>
) : null} ) : null}
<div className={styles.modalActions}> <div className={styles.modalActions}>
<span <span
className={`${styles.testStatus} ${ className={`${styles.testStatus} ${
testResult?.ok testStatus === 'passed'
? styles.success ? styles.success
: testResult && !testResult.ok : testStatus === 'failed'
? styles.error ? styles.error
: '' : ''
}`} }`}
> >
{testResult?.ok {testStatus === 'passed'
? byokT(t, 'status.key-verified') ? byokT(t, 'probe.verified')
: testResult : testStatus === 'failed'
? byokT(t, 'status.key-test-failed') ? byokT(t, 'probe.failed')
: ''} : ''}
</span> </span>
<Button <Button
variant="secondary" variant="secondary"
disabled={!canTest || testing} disabled={!valid || busy}
onClick={() => { onClick={() => {
testKey().catch(error => { testConnection().catch(error => {
logByokError('Failed to test BYOK key', error); logByokError('Failed to test BYOK provider', error);
notify.error({ setTestStatus('failed');
title: byokT(t, 'notify.test-failed.title'),
message: byokT(t, 'notify.operation-failed.message'),
});
}); });
}} }}
> >
{byokT(t, 'action.test-key')} {byokT(t, 'action.test-connection')}
</Button> </Button>
<Button variant="secondary" onClick={() => onOpenChange(false)}> <Button variant="secondary" onClick={() => onOpenChange(false)}>
{byokT(t, 'action.cancel')} {byokT(t, 'action.cancel')}
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
disabled={!testResult?.ok || !name} disabled={!valid || busy}
onClick={() => { onClick={() => {
save().catch(error => { connect().catch(error => {
logByokError('Failed to save BYOK key', error); logByokError('Failed to save BYOK provider', error);
notify.error({ notify.error({
title: byokT(t, 'notify.save-failed.title'), title: byokT(t, 'notify.save-failed.title'),
message: byokT(t, 'notify.operation-failed.message'), message: byokT(t, 'notify.operation-failed.message'),
@@ -353,7 +558,14 @@ export const AddKeyModal = ({
}); });
}} }}
> >
{byokT(t, 'action.save-key')} {byokT(
t,
busy
? 'action.connecting'
: editingKey
? 'action.save-changes'
: 'action.connect'
)}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -9,8 +9,8 @@ import {
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import * as styles from './index.css'; import * as styles from './index.css';
import { byokT, capabilityRows, warningDescription } from './metadata'; import { byokT, capabilityRows } from './metadata';
import type { ByokKey, ByokSettings } from './types'; import type { ByokKey } from './types';
function coverageIcon( function coverageIcon(
icon: (typeof capabilityRows)[number]['icon'] icon: (typeof capabilityRows)[number]['icon']
@@ -47,13 +47,7 @@ function isRowCovered(row: (typeof capabilityRows)[number], keys: ByokKey[]) {
}); });
} }
export const CoveragePanel = ({ export const CoveragePanel = ({ keys }: { keys: ByokKey[] }) => {
keys,
settings,
}: {
keys: ByokKey[];
settings: ByokSettings;
}) => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -63,9 +57,6 @@ export const CoveragePanel = ({
</div> </div>
<div className={styles.rows}> <div className={styles.rows}>
{capabilityRows.map(row => { {capabilityRows.map(row => {
const warning = settings.warnings.find(
w => w.featureKind === row.featureKind
);
const covered = isRowCovered(row, keys); const covered = isRowCovered(row, keys);
return ( return (
<div <div
@@ -86,7 +77,7 @@ export const CoveragePanel = ({
<div className={styles.rowMain}> <div className={styles.rowMain}>
<div className={styles.rowTitle}>{byokT(t, row.titleKey)}</div> <div className={styles.rowTitle}>{byokT(t, row.titleKey)}</div>
<div className={styles.rowDescription}> <div className={styles.rowDescription}>
{warningDescription(t, warning) ?? byokT(t, row.fallbackKey)} {byokT(t, row.fallbackKey)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -86,7 +86,7 @@ export const capabilityIcon = style({
export const capabilityIconActive = style({ export const capabilityIconActive = style({
color: cssVarV2('button/primary'), color: cssVarV2('button/primary'),
background: '#f0f7ff', background: cssVarV2('chip/label/blue'),
}); });
export const capabilityIconSvg = style({ export const capabilityIconSvg = style({
@@ -176,14 +176,16 @@ export const locked = style({
export const form = style({ export const form = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 12, gap: 16,
maxHeight: 'min(720px, calc(100vh - 180px))',
overflowY: 'auto',
paddingRight: 2,
}); });
export const field = style({ export const field = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: 4,
height: '3em',
}); });
export const endpointField = style([field, { height: 'auto' }]); export const endpointField = style([field, { height: 'auto' }]);
@@ -201,6 +203,8 @@ export const fieldHint = style({
export const input = style({ export const input = style({
height: 32, height: 32,
minHeight: 32,
maxHeight: 32,
width: '100%', width: '100%',
boxSizing: 'border-box', boxSizing: 'border-box',
borderRadius: 8, borderRadius: 8,
@@ -228,6 +232,319 @@ export const modalActions = style({
justifyContent: 'flex-end', justifyContent: 'flex-end',
gap: 8, gap: 8,
marginTop: 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({ export const testStatus = style({

View File

@@ -6,10 +6,10 @@ import {
import { WorkspaceServerService } from '@affine/core/modules/cloud'; import { WorkspaceServerService } from '@affine/core/modules/cloud';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { import {
ByokKeyStorage, deleteWorkspaceByokProfileMutation,
clearWorkspaceByokConfigsMutation as clearByokMutation,
deleteWorkspaceByokConfigMutation as deleteByokMutation,
type GraphQLQuery, type GraphQLQuery,
probeWorkspaceByokProfileMutation,
reorderWorkspaceByokProfilesMutation,
ServerDeploymentType, ServerDeploymentType,
workspaceByokSettingsQuery as byokSettingsQuery, workspaceByokSettingsQuery as byokSettingsQuery,
} from '@affine/graphql'; } from '@affine/graphql';
@@ -29,27 +29,12 @@ import {
readLocalKeys, readLocalKeys,
reorderLocalKeys, reorderLocalKeys,
} from './local-storage'; } from './local-storage';
import { byokT } from './metadata'; import { byokT, capabilitiesFor } from './metadata';
import type { import { probeChecks } from './model-utils';
ByokKey, import type { ByokKey, ByokSettings, ByokUsagePoint, GqlFn } from './types';
ByokSettings, import { ByokStorage } from './types';
ByokStorage,
ByokUsagePoint,
GqlFn,
} from './types';
import { UsagePanel } from './usage'; 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 = () => { export const WorkspaceByokSetting = () => {
const t = useI18n(); const t = useI18n();
const workspace = useService(WorkspaceService).workspace; const workspace = useService(WorkspaceService).workspace;
@@ -59,6 +44,7 @@ export const WorkspaceByokSetting = () => {
const [localKeys, setLocalKeys] = useState<ByokKey[]>([]); const [localKeys, setLocalKeys] = useState<ByokKey[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingKey, setEditingKey] = useState<ByokKey | null>(null); const [editingKey, setEditingKey] = useState<ByokKey | null>(null);
const [testingKeyId, setTestingKeyId] = useState<string | null>(null);
const [draggingKey, setDraggingKey] = useState<{ const [draggingKey, setDraggingKey] = useState<{
id: string; id: string;
storage: ByokStorage; storage: ByokStorage;
@@ -83,8 +69,27 @@ export const WorkspaceByokSetting = () => {
localByokStorageSupported(), localByokStorageSupported(),
readLocalKeys(workspace.id), 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({ setSettings({
...data.workspace.byokSettings, ...data.workspace.byokSettings,
keys: serverKeys,
localStorageSupported: localStorageSupported:
data.workspace.byokSettings.localEntitled && localStorageSupported, data.workspace.byokSettings.localEntitled && localStorageSupported,
}); });
@@ -105,7 +110,7 @@ export const WorkspaceByokSetting = () => {
const keys = useMemo(() => { const keys = useMemo(() => {
return [...localKeys, ...(settings?.keys ?? [])].toSorted((a, b) => { return [...localKeys, ...(settings?.keys ?? [])].toSorted((a, b) => {
if (a.storage !== b.storage) { if (a.storage !== b.storage) {
return a.storage === ByokKeyStorage.local ? -1 : 1; return a.storage === ByokStorage.local ? -1 : 1;
} }
return a.sortOrder - b.sortOrder; return a.sortOrder - b.sortOrder;
}); });
@@ -123,26 +128,35 @@ export const WorkspaceByokSetting = () => {
if (!settings) { if (!settings) {
return; return;
} }
if (!workspaceServer.server && settings.serverEntitled) { const deletions: Promise<unknown>[] = [];
return;
}
if (settings.serverEntitled && workspaceServer.server) { if (settings.serverEntitled && workspaceServer.server) {
const gql = workspaceServer.server.gql as GqlFn; const gql = workspaceServer.server.gql as GqlFn;
await gql({ deletions.push(
query: clearByokMutation, ...settings.keys.map(key =>
variables: { workspaceId: workspace.id }, gql({
}); query: deleteWorkspaceByokProfileMutation,
variables: { workspaceId: workspace.id, profileId: key.id },
})
)
);
} }
if (settings.localStorageSupported) { if (settings.localStorageSupported) {
await clearLocalKeys(workspace.id); deletions.push(clearLocalKeys(workspace.id));
} }
setLocalKeys([]); const results = await Promise.allSettled(deletions);
await load(); 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]); }, [load, settings, workspace.id, workspaceServer.server]);
const deleteKey = useCallback( const deleteKey = useCallback(
async (key: ByokKey) => { async (key: ByokKey) => {
if (key.storage === ByokKeyStorage.local) { if (key.storage === ByokStorage.local) {
await deleteLocalKey(workspace.id, key.id); await deleteLocalKey(workspace.id, key.id);
setLocalKeys(await readLocalKeys(workspace.id)); setLocalKeys(await readLocalKeys(workspace.id));
return; return;
@@ -154,14 +168,39 @@ export const WorkspaceByokSetting = () => {
}) => Promise<unknown>) }) => Promise<unknown>)
| undefined; | undefined;
await gql?.({ await gql?.({
query: deleteByokMutation, query: deleteWorkspaceByokProfileMutation,
variables: { workspaceId: workspace.id, id: key.id }, variables: { workspaceId: workspace.id, profileId: key.id },
}); });
await load(); await load();
}, },
[load, workspace.id, workspaceServer.server] [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( const reorderKey = useCallback(
async (targetKey: ByokKey) => { async (targetKey: ByokKey) => {
if (!draggingKey || draggingKey.id === targetKey.id) { if (!draggingKey || draggingKey.id === targetKey.id) {
@@ -187,38 +226,44 @@ export const WorkspaceByokSetting = () => {
nextBucket.splice(toIndex, 0, moved); nextBucket.splice(toIndex, 0, moved);
const nextBucketIds = nextBucket.map(key => key.id); const nextBucketIds = nextBucket.map(key => key.id);
if (targetKey.storage === ByokKeyStorage.local) { if (targetKey.storage === ByokStorage.local) {
setLocalKeys(await reorderLocalKeys(workspace.id, nextBucketIds)); setLocalKeys(await reorderLocalKeys(workspace.id, nextBucketIds));
return; } else if (workspaceServer.server) {
} if (nextBucket.some(key => key.revision === undefined)) {
notify.error({
const gql = workspaceServer.server?.gql as title: byokT(t, 'notify.reload-required.title'),
| ((input: { message: byokT(t, 'notify.reload-required.message'),
query: GraphQLQuery; });
variables?: Record<string, unknown>; await load();
}) => Promise<unknown>) return;
| undefined; }
await gql?.({ await (workspaceServer.server.gql as GqlFn)({
query: reorderByokMutation, query: reorderWorkspaceByokProfilesMutation,
variables: { variables: {
input: { input: {
workspaceId: workspace.id, workspaceId: workspace.id,
storage: ByokKeyStorage.server, profiles: nextBucket.flatMap(key =>
ids: nextBucketIds, key.revision === undefined
? []
: [
{
profileId: key.id,
expectedRevision: key.revision,
},
]
),
},
}, },
}, });
}); await load();
await load(); }
}, },
[draggingKey, keys, load, t, workspace.id, workspaceServer.server] [draggingKey, keys, load, t, workspace.id, workspaceServer.server]
); );
if (!settings) { if (!settings) {
return ( return (
<SettingHeader <SettingHeader title={byokT(t, 'title')} subtitle={byokT(t, 'loading')} />
title={byokT(t, 'title-beta')}
subtitle={byokT(t, 'loading')}
/>
); );
} }
@@ -226,7 +271,7 @@ export const WorkspaceByokSetting = () => {
return ( return (
<> <>
<SettingHeader <SettingHeader
title={byokT(t, 'title-beta')} title={byokT(t, 'title')}
subtitle={byokT(t, 'subtitle')} subtitle={byokT(t, 'subtitle')}
/> />
<SettingWrapper> <SettingWrapper>
@@ -237,13 +282,7 @@ export const WorkspaceByokSetting = () => {
{byokT(t, 'locked.description')} {byokT(t, 'locked.description')}
</div> </div>
</div> </div>
<div className={styles.tags}> <div className={styles.tags}></div>
{settings.entitlementRequired.map(plan => (
<span className={styles.tag} key={plan}>
{plan}
</span>
))}
</div>
</div> </div>
</SettingWrapper> </SettingWrapper>
</> </>
@@ -252,21 +291,9 @@ export const WorkspaceByokSetting = () => {
return ( return (
<> <>
<SettingHeader <SettingHeader title={byokT(t, 'title')} subtitle={byokT(t, 'header')} />
title={byokT(t, 'title-beta')}
subtitle={byokT(t, 'header')}
/>
<SettingWrapper> <SettingWrapper>
<div className={styles.stack}> <div className={styles.stack}>
{settings.hasAiPlan ? (
<div className={styles.notice}>
<div className={styles.title}>{byokT(t, 'notice.title')}</div>
<div className={styles.description}>
{byokT(t, 'notice.description')}
</div>
</div>
) : null}
<div className={styles.panel} data-testid="workspace-byok-keys"> <div className={styles.panel} data-testid="workspace-byok-keys">
<div className={styles.panelHeader}> <div className={styles.panelHeader}>
<div> <div>
@@ -289,6 +316,7 @@ export const WorkspaceByokSetting = () => {
{keys.length ? ( {keys.length ? (
<KeyList <KeyList
keys={keys} keys={keys}
testingKeyId={testingKeyId}
onEdit={key => { onEdit={key => {
setEditingKey(key); setEditingKey(key);
setModalOpen(true); 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 => { onDragStart={key => {
setDraggingKey({ id: key.id, storage: key.storage }); setDraggingKey({ id: key.id, storage: key.storage });
}} }}
@@ -326,7 +363,7 @@ export const WorkspaceByokSetting = () => {
)} )}
</div> </div>
<CoveragePanel keys={keys} settings={settings} /> <CoveragePanel keys={keys} />
<UsagePanel <UsagePanel
keys={keys} keys={keys}

View File

@@ -1,4 +1,4 @@
import { DragHandle, IconButton } from '@affine/component'; import { Button, DragHandle, IconButton } from '@affine/component';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { DeleteIcon, EditIcon } from '@blocksuite/icons/rc'; import { DeleteIcon, EditIcon } from '@blocksuite/icons/rc';
import type { DragEvent } from 'react'; import type { DragEvent } from 'react';
@@ -11,19 +11,23 @@ import {
rowDescription, rowDescription,
storageLabel, storageLabel,
} from './metadata'; } from './metadata';
import type { ByokKey } from './types'; import { type ByokKey, ByokStorage } from './types';
export const KeyList = ({ export const KeyList = ({
keys, keys,
testingKeyId,
onEdit, onEdit,
onDelete, onDelete,
onTest,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
onDrop, onDrop,
}: { }: {
keys: ByokKey[]; keys: ByokKey[];
testingKeyId: string | null;
onEdit: (key: ByokKey) => void; onEdit: (key: ByokKey) => void;
onDelete: (key: ByokKey) => void; onDelete: (key: ByokKey) => void;
onTest: (key: ByokKey) => void;
onDragStart: (key: ByokKey) => void; onDragStart: (key: ByokKey) => void;
onDragEnd: () => void; onDragEnd: () => void;
onDrop: (key: ByokKey) => void; onDrop: (key: ByokKey) => void;
@@ -72,6 +76,18 @@ export const KeyList = ({
</div> </div>
</div> </div>
<div className={styles.rowActions}> <div className={styles.rowActions}>
{key.storage === ByokStorage.server ? (
<Button
variant="plain"
disabled={testingKeyId !== null}
onClick={() => onTest(key)}
>
{byokT(
t,
testingKeyId === key.id ? 'action.testing' : 'action.test'
)}
</Button>
) : null}
<IconButton <IconButton
size="20" size="20"
title={byokT(t, 'action.edit')} title={byokT(t, 'action.edit')}

View File

@@ -1,8 +1,12 @@
import { apis } from '@affine/electron-api'; import { apis } from '@affine/electron-api';
import { ByokKeyStorage, ByokKeyTestStatus } from '@affine/graphql';
import { capabilitiesFor } from './metadata'; import { capabilitiesFor } from './metadata';
import type { ByokKey, LocalByokKeyInput, LocalByokPublicKey } from './types'; import {
type ByokKey,
ByokStorage,
type LocalByokKeyInput,
type LocalByokPublicKey,
} from './types';
function byokStorageApi() { function byokStorageApi() {
return BUILD_CONFIG.isElectron ? apis?.byokStorage : undefined; return BUILD_CONFIG.isElectron ? apis?.byokStorage : undefined;
@@ -26,14 +30,12 @@ function toLocalByokKey(key: LocalByokPublicKey): ByokKey {
provider: key.provider, provider: key.provider,
name: key.name, name: key.name,
description: key.description ?? null, description: key.description ?? null,
storage: ByokKeyStorage.local, storage: ByokStorage.local,
configured: key.configured ?? true, configured: key.configured ?? true,
enabled: key.enabled ?? true, enabled: key.enabled ?? true,
endpoint: key.endpoint ?? null,
endpointEditable: key.endpointEditable ?? false,
sortOrder: key.sortOrder ?? 0, sortOrder: key.sortOrder ?? 0,
capabilities: capabilitiesFor(key.provider, ByokKeyStorage.local), definition: key.definition,
testStatus: key.testStatus ?? ByokKeyTestStatus.passed, capabilities: capabilitiesFor(key),
}; };
} }

View File

@@ -1,34 +0,0 @@
import { describe, expect, test } from 'vitest';
import { endpointHintKey, shouldShowEndpoint } from './metadata';
describe('endpointHintKey', () => {
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
);
}
);
});

View File

@@ -1,7 +1,7 @@
import { ByokKeyStorage, ByokProvider } from '@affine/graphql'; import { ByokProvider } from '@affine/graphql';
import type { I18nInstance } from '@affine/i18n'; import type { I18nInstance } from '@affine/i18n';
import type { ByokKey, ByokStorage } from './types'; import { type ByokKey, ByokStorage } from './types';
export function byokT( export function byokT(
t: I18nInstance, t: I18nInstance,
@@ -19,7 +19,7 @@ export const providerLabels: Record<ByokProvider, string> = {
}; };
export function storageLabel(t: I18nInstance, storage: ByokStorage) { export function storageLabel(t: I18nInstance, storage: ByokStorage) {
return storage === ByokKeyStorage.local return storage === ByokStorage.local
? byokT(t, 'storage.local') ? byokT(t, 'storage.local')
: byokT(t, 'storage.server'); : byokT(t, 'storage.server');
} }
@@ -44,26 +44,20 @@ export function shouldShowEndpoint(
return isSelfHosted || customEndpointSupported; return isSelfHosted || customEndpointSupported;
} }
export function capabilitiesFor(provider: ByokProvider, storage: ByokStorage) { export function capabilitiesFor(key: Pick<ByokKey, 'definition'>) {
switch (provider) { const capabilities = key.definition.models.flatMap(model =>
case ByokProvider.openai: model.enabled ? model.capabilities : []
return ['Text', 'Image input', 'Actions', 'Image generate']; );
case ByokProvider.anthropic: const labels = new Set<string>();
return ['Text', 'Image input']; for (const capability of capabilities) {
case ByokProvider.gemini: if (capability.output.includes('text')) labels.add('Text');
return storage === ByokKeyStorage.server if (capability.input.includes('image')) labels.add('Image input');
? [ if (capability.output.includes('image')) labels.add('Image generate');
'Text', if (capability.features.includes('tools')) labels.add('Actions');
'Image input', if (capability.input.includes('audio')) labels.add('Transcript');
'Actions', if (capability.output.includes('embedding')) labels.add('Indexing');
'Image generate',
'Transcript',
'Indexing',
]
: ['Text', 'Image input', 'Actions', 'Image generate'];
case ByokProvider.fal:
return ['Image generate'];
} }
return [...labels];
} }
export function capabilityLabel(t: I18nInstance, capability: string) { export function capabilityLabel(t: I18nInstance, capability: string) {
@@ -121,7 +115,7 @@ export const capabilityRows = [
icon: 'transcript', icon: 'transcript',
providers: [ByokProvider.gemini], providers: [ByokProvider.gemini],
coverageCapabilities: ['Transcript'], coverageCapabilities: ['Transcript'],
storage: ByokKeyStorage.server, storage: ByokStorage.server,
}, },
{ {
titleKey: 'feature.workspace-indexing.title', titleKey: 'feature.workspace-indexing.title',
@@ -130,7 +124,7 @@ export const capabilityRows = [
icon: 'indexing', icon: 'indexing',
providers: [ByokProvider.gemini], providers: [ByokProvider.gemini],
coverageCapabilities: ['Indexing'], coverageCapabilities: ['Indexing'],
storage: ByokKeyStorage.server, storage: ByokStorage.server,
}, },
] as const; ] as const;
@@ -145,16 +139,13 @@ function formatDate(value?: string | null) {
} }
export function rowDescription(t: I18nInstance, key: ByokKey) { export function rowDescription(t: I18nInstance, key: ByokKey) {
const failed = formatDate(key.lastErrorAt); const tested = formatDate(key.validation?.connection.testedAt);
const used = formatDate(key.lastUsedAt); const activity =
const today = formatDate(new Date().toISOString()); key.validation?.connection.kind === 'failed'
const activity = failed ? byokT(t, 'row.activity.failed', { date: tested ?? '' })
? byokT(t, 'row.activity.failed', { date: failed }) : key.validation?.connection.kind === 'verified'
: used ? byokT(t, 'status.key-verified')
? used === today : byokT(t, 'row.activity.unused');
? byokT(t, 'row.activity.used-today')
: byokT(t, 'row.activity.used', { date: used })
: byokT(t, 'row.activity.unused');
return [storageLabel(t, key.storage), activity, key.description] return [storageLabel(t, key.storage), activity, key.description]
.filter(Boolean) .filter(Boolean)

View File

@@ -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<typeof catalogModels>;
models: ModelDeclaration[];
editingModel: ModelDeclaration | null;
onOpenChange: (open: boolean) => void;
onSubmit: (models: ModelDeclaration[]) => void;
}) => {
const t = useI18n();
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [modelId, setModelId] = useState('');
const [selectedUseCases, setSelectedUseCases] = useState<UseCase[]>(['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 (
<Modal
width={customEndpoint ? 520 : 560}
open={open}
onOpenChange={onOpenChange}
descriptionClassName={styles.modelModalDescription}
title={byokT(
t,
editingModel
? 'modal.edit-model-title'
: customEndpoint
? 'modal.add-custom-model-title'
: 'modal.add-model-title'
)}
description={byokT(
t,
customEndpoint
? 'modal.custom-model-description'
: 'modal.catalog-model-description'
)}
>
{customEndpoint ? (
<div className={styles.modelModalBody}>
<label className={styles.field}>
<span className={styles.modelFieldLabel}>
{byokT(t, 'field.model-id')}
</span>
<Input
size="large"
value={modelId}
onChange={setModelId}
placeholder={byokT(t, 'placeholder.model-id')}
/>
{duplicateModelId ? (
<span className={styles.error}>
{byokT(t, 'model.duplicate-id')}
</span>
) : null}
</label>
<fieldset className={styles.modelCapabilities}>
<legend className={styles.modelFieldLabel}>
{byokT(t, 'model.use-this-for')}
</legend>
<div className={styles.useCaseGrid}>
{useCases.map(useCase => (
<Checkbox
className={styles.modelUseCase}
labelClassName={styles.modelUseCaseLabel}
key={useCase.id}
name={`byok-model-use-${useCase.id}`}
label={byokT(t, useCase.labelKey)}
checked={selectedUseCases.includes(useCase.id)}
onChange={(_, checked) =>
setSelectedUseCases(
checked
? [...selectedUseCases, useCase.id]
: selectedUseCases.filter(item => item !== useCase.id)
)
}
/>
))}
</div>
</fieldset>
</div>
) : (
<div className={styles.modelModalBody}>
{availableCatalog.length > 6 ? (
<Input
className={styles.modelSearch}
size="large"
value={search}
onChange={setSearch}
placeholder={byokT(t, 'placeholder.search-models')}
/>
) : null}
<div className={styles.catalogChoices}>
{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 (
<label
className={styles.catalogChoice}
data-selected={selectedIds.includes(model.modelId)}
key={model.modelId}
>
<Checkbox
className={styles.modelCheckbox}
aria-label={model.displayName}
checked={selectedIds.includes(model.modelId)}
onChange={(_, checked) =>
setSelectedIds(
checked
? [...selectedIds, model.modelId]
: selectedIds.filter(id => id !== model.modelId)
)
}
/>
<span className={styles.catalogModelCopy}>
<span className={styles.catalogModelTitle}>
<strong>{model.displayName}</strong>
{model.recommended ? (
<span className={styles.recommended}>
{byokT(t, 'model.recommended')}
</span>
) : null}
</span>
<span
className={styles.catalogModelMeta}
title={[model.modelId, ...visibleUses].join(' · ')}
>
{[model.modelId, ...visibleUses].join(' · ')}
</span>
</span>
</label>
);
})
) : (
<div className={styles.modelEmpty}>
{byokT(
t,
search ? 'models.no-search-results' : 'models.all-added'
)}
</div>
)}
</div>
</div>
)}
<div className={styles.modelModalActions}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
{byokT(t, 'action.cancel')}
</Button>
<Button variant="primary" disabled={!valid} onClick={submit}>
{byokT(
t,
editingModel
? 'action.save-model'
: customEndpoint
? 'action.add-model'
: 'action.add-selected-models',
{ count: selectedIds.length }
)}
</Button>
</div>
</Modal>
);
};

View File

@@ -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<typeof catalogModels>;
models: ModelDeclaration[];
validation?: ByokKey['validation'];
onChange: (models: ModelDeclaration[]) => void;
}) => {
const t = useI18n();
const [editorOpen, setEditorOpen] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draggingIndex, setDraggingIndex] = useState<number | null>(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 (
<>
<div className={styles.modelToolbar}>
<span className={styles.description}>
{byokT(t, 'models.description.order')}
</span>
<Button
variant="secondary"
onClick={() => {
setEditingIndex(null);
setEditorOpen(true);
}}
>
{byokT(t, 'action.add-model')}
</Button>
</div>
{models.length ? (
<ol className={styles.selectedModels}>
{models.map((model, index) => {
const catalogModel = catalog.find(
item => item.modelId === model.modelId
);
const selected = modelUseCases(model);
return (
<li
className={`${styles.selectedModel} ${
model.enabled ? '' : styles.selectedModelDisabled
}`}
key={model.modelId}
onDragOver={event => event.preventDefault()}
onDrop={event => {
event.preventDefault();
drop(index);
}}
>
<div
className={styles.modelDragHandle}
draggable
title={byokT(t, 'action.reorder')}
onDragStart={() => setDraggingIndex(index)}
onDragEnd={() => setDraggingIndex(null)}
>
<DragHandle dragging={draggingIndex === index} />
</div>
<div className={styles.modelCopy}>
<strong>{catalogModel?.displayName ?? model.modelId}</strong>
{catalogModel?.displayName ? (
<small>{model.modelId}</small>
) : null}
<span className={styles.tags}>
{selected.slice(0, 3).map(useCase => {
const item = useCases.find(item => item.id === useCase);
return item ? (
<span className={styles.tag} key={useCase}>
{byokT(t, item.labelKey)}
</span>
) : null;
})}
{selected.length > 3 ? (
<span className={styles.tag}>+{selected.length - 3}</span>
) : null}
</span>
</div>
<span className={styles.modelStatus}>
{model.enabled
? evidence(model.modelId)
: byokT(t, 'model.status.disabled')}
</span>
<Switch
checked={model.enabled}
aria-label={byokT(
t,
model.enabled
? 'action.disable-model'
: 'action.enable-model',
{ model: catalogModel?.displayName ?? model.modelId }
)}
onChange={enabled => update(index, { ...model, enabled })}
/>
<Menu
items={
<>
{customEndpoint ? (
<MenuItem
onSelect={() => {
setEditingIndex(index);
setEditorOpen(true);
}}
>
{byokT(t, 'action.edit')}
</MenuItem>
) : null}
<MenuItem
disabled={index === 0}
onSelect={() => move(index, -1)}
>
{byokT(t, 'action.move-up')}
</MenuItem>
<MenuItem
disabled={index === models.length - 1}
onSelect={() => move(index, 1)}
>
{byokT(t, 'action.move-down')}
</MenuItem>
<MenuItem
type="danger"
onSelect={() =>
onChange(models.filter((_, i) => i !== index))
}
>
{byokT(t, 'action.remove')}
</MenuItem>
</>
}
>
<IconButton
size="20"
title={byokT(t, 'action.model-options', {
model: catalogModel?.displayName ?? model.modelId,
})}
icon={<MoreHorizontalIcon />}
/>
</Menu>
</li>
);
})}
</ol>
) : (
<div className={styles.modelEmpty}>{byokT(t, 'models.empty')}</div>
)}
<ModelEditorModal
open={editorOpen}
customEndpoint={customEndpoint}
catalog={catalog}
models={models}
editingModel={editingIndex === null ? null : models[editingIndex]}
onOpenChange={open => {
setEditorOpen(open);
if (!open) setEditingIndex(null);
}}
onSubmit={next => {
if (editingIndex === null) {
onChange([...models, ...next]);
} else if (next[0]) {
update(editingIndex, next[0]);
}
setEditingIndex(null);
}}
/>
</>
);
};

View File

@@ -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]);
});
});

View File

@@ -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,
}));
}

View File

@@ -1,76 +1,64 @@
import { import {
type ByokKeyStorage,
type ByokKeyTestStatus,
type ByokProvider, type ByokProvider,
type GraphQLQuery, type GraphQLQuery,
type QueryOptions, type QueryOptions,
type QueryResponse, type QueryResponse,
type WorkspaceByokSettingsQuery,
} from '@affine/graphql'; } 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; id: string;
provider: ByokProvider; provider: ByokProvider;
name: string; name: string;
description?: string | null; description?: string | null;
storage: ByokStorage;
configured: boolean; configured: boolean;
enabled: boolean; enabled: boolean;
endpoint?: string | null;
endpointEditable: boolean;
sortOrder: number; sortOrder: number;
definition: ByokDefinition;
capabilities: string[]; capabilities: string[];
testStatus: ByokKeyTestStatus; validation?: WorkspaceByokSettingsQuery['workspace']['byokSettings']['profiles'][number]['validation'];
disabledReason?: string | null;
lastTestedAt?: string | null;
lastTestError?: string | null;
lastUsedAt?: string | null;
lastErrorAt?: string | null;
lastError?: string | null;
}; };
export type ByokKey = ByokKeyBase &
(
| { storage: typeof ByokStorage.server; revision: number }
| { storage: typeof ByokStorage.local; revision?: never }
);
export type LocalByokKeyInput = Pick< export type LocalByokKeyInput = Pick<
ByokKey, ByokKey,
| 'id' | 'id'
| 'provider' | 'provider'
| 'name' | 'name'
| 'description' | 'description'
| 'endpoint'
| 'sortOrder' | 'sortOrder'
| 'enabled' | '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[]; keys: ByokKey[];
allowedProviders: ByokProvider[];
localStorageSupported: boolean; localStorageSupported: boolean;
customEndpointSupported: boolean;
privateEndpointSupported: boolean;
hasAiPlan: boolean;
warnings: Array<{
featureKind: string;
reason: string;
requiredProviders: ByokProvider[];
}>;
}; };
export type ByokUsagePoint = { export type ByokUsagePoint =
date: string; WorkspaceByokSettingsQuery['workspace']['byokUsage'][number];
featureKind: string;
totalTokens: number;
};
export type ByokTestResult = { export type ByokTestResult = {
ok: boolean; ok: boolean;
status: ByokKey['testStatus']; status: string;
message?: string | null; message?: string | null;
}; };
@@ -78,15 +66,6 @@ export type GqlFn = <Query extends GraphQLQuery>(
input: QueryOptions<Query> input: QueryOptions<Query>
) => Promise<QueryResponse<Query>>; ) => Promise<QueryResponse<Query>>;
export type LocalByokPublicKey = { export type LocalByokPublicKey = Omit<LocalByokKeyInput, 'credential'> & {
id: string;
provider: ByokProvider;
name: string;
description?: string | null;
endpoint?: string | null;
endpointEditable?: boolean;
sortOrder?: number | null;
enabled?: boolean | null;
configured?: boolean; configured?: boolean;
testStatus?: ByokKey['testStatus'];
}; };

View File

@@ -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 { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import { import {
AIDraftService, AIDraftService,
AIModelService,
AIToolsConfigService, AIToolsConfigService,
} from '@affine/core/modules/ai-button'; } from '@affine/core/modules/ai-button';
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
import { import {
EventSourceService, EventSourceService,
GraphQLService, GraphQLService,
@@ -197,9 +197,9 @@ export const Component = () => {
content.notificationService = notificationService; content.notificationService = notificationService;
content.aiDraftService = framework.get(AIDraftService); content.aiDraftService = framework.get(AIDraftService);
content.aiToolsConfigService = framework.get(AIToolsConfigService); content.aiToolsConfigService = framework.get(AIToolsConfigService);
content.aiModelService = framework.get(AIModelService);
content.serverService = framework.get(ServerService); content.serverService = framework.get(ServerService);
content.subscriptionService = framework.get(SubscriptionService); content.subscriptionService = framework.get(SubscriptionService);
content.aiModelService = framework.get(AIModelService);
content.onAISubscribe = handleAISubscribe; content.onAISubscribe = handleAISubscribe;
content.onOpenDoc = onOpenDoc; content.onOpenDoc = onOpenDoc;
}, },

View File

@@ -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 { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import { import {
AIDraftService, AIDraftService,
AIModelService,
AIToolsConfigService, AIToolsConfigService,
} from '@affine/core/modules/ai-button'; } from '@affine/core/modules/ai-button';
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
import { import {
EventSourceService, EventSourceService,
GraphQLService, GraphQLService,
@@ -283,9 +283,9 @@ export const EditorChatPanel = ({
content.notificationService = notificationService; content.notificationService = notificationService;
content.aiDraftService = framework.get(AIDraftService); content.aiDraftService = framework.get(AIDraftService);
content.aiToolsConfigService = framework.get(AIToolsConfigService); content.aiToolsConfigService = framework.get(AIToolsConfigService);
content.aiModelService = framework.get(AIModelService);
content.peekViewService = framework.get(PeekViewService); content.peekViewService = framework.get(PeekViewService);
content.subscriptionService = framework.get(SubscriptionService); content.subscriptionService = framework.get(SubscriptionService);
content.aiModelService = framework.get(AIModelService);
content.onAISubscribe = handleAISubscribe; content.onAISubscribe = handleAISubscribe;
content.width = sidebarWidthSignal; content.width = sidebarWidthSignal;
content.onOpenDoc = (docId: string, sessionId?: string) => { content.onOpenDoc = (docId: string, sessionId?: string) => {
@@ -399,8 +399,8 @@ export const EditorChatPanel = ({
.notificationService=${notificationService} .notificationService=${notificationService}
.affineWorkspaceDialogService=${framework.get(WorkspaceDialogService)} .affineWorkspaceDialogService=${framework.get(WorkspaceDialogService)}
.aiToolsConfigService=${framework.get(AIToolsConfigService)} .aiToolsConfigService=${framework.get(AIToolsConfigService)}
.subscriptionService=${framework.get(SubscriptionService)}
.aiModelService=${framework.get(AIModelService)} .aiModelService=${framework.get(AIModelService)}
.subscriptionService=${framework.get(SubscriptionService)}
></playground-content> ></playground-content>
`; `;

View File

@@ -1,6 +1,7 @@
export { AIButtonProvider } from './provider/ai-button'; export { AIButtonProvider } from './provider/ai-button';
export { AIButtonService } from './services/ai-button'; export { AIButtonService } from './services/ai-button';
export { AIDraftService } from './services/ai-draft'; export { AIDraftService } from './services/ai-draft';
export { AIModelService } from './services/models';
export { export {
type AIToolsConfig, type AIToolsConfig,
AIToolsConfigService, AIToolsConfigService,

View File

@@ -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<string, unknown>();
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: [] } } },
});
});
});

View File

@@ -1,112 +1,109 @@
import { getPromptModelsQuery, SubscriptionStatus } from '@affine/graphql'; import { getCopilotRouteOptionsQuery } from '@affine/graphql';
import {
createSignalFromObservable,
type Signal,
} from '@blocksuite/affine/shared/utils';
import { signal } from '@preact/signals-core'; 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 { GraphQLService, SubscriptionService } from '../../cloud';
import type { GlobalStateService } from '../../storage'; import type { GlobalStateService } from '../../storage';
const AI_MODEL_ID_KEY = 'AIModelId'; const ROUTE_TARGET_KEY = 'AIManagedRouteTarget';
export interface AIModel { export interface AIModel {
name: string;
id: string; id: string;
version: string; name: string;
category: string; category: string;
isPro: boolean; version: string;
isDefault: boolean; available: boolean;
} }
export class AIModelService extends Service { export class AIModelService extends Service {
modelId: Signal<string | undefined>; readonly modelId = signal<string | undefined>(undefined);
readonly models = signal<AIModel[]>([]);
models: Signal<AIModel[]> = signal([]); private workspaceId: string | undefined;
private routeId: string | undefined;
private readonly modelId$ = LiveData.from( private requestId = 0;
this.globalStateService.globalState.watch<string>(AI_MODEL_ID_KEY),
undefined
);
constructor( constructor(
private readonly globalStateService: GlobalStateService, private readonly globalStateService: GlobalStateService,
private readonly gqlService: GraphQLService, private readonly gqlService: GraphQLService,
private readonly subscriptionService: SubscriptionService subscriptionService: SubscriptionService
) { ) {
super(); super();
const subscription = subscriptionService.subscription.ai$.subscribe(() => {
const { signal: modelId, cleanup } = createSignalFromObservable< if (this.workspaceId && this.routeId) {
string | undefined this.load(this.workspaceId, this.routeId).catch(console.error);
>(this.modelId$, undefined); }
this.modelId = modelId;
this.disposables.push(cleanup);
this.init().catch(err => {
console.error(err);
}); });
this.disposables.push(() => subscription.unsubscribe());
} }
resetModel = () => { setScope(workspaceId: string, routeId: string) {
this.globalStateService.globalState.set(AI_MODEL_ID_KEY, undefined); 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) => { resetModel() {
const isSubscribed = this.setModel(undefined);
this.subscriptionService.subscription.ai$.value?.status === }
SubscriptionStatus.Active;
const model = this.models.value.find(model => model.id === modelId); setModel(modelId: string | undefined) {
if (!isSubscribed && model?.isPro) { if (!this.workspaceId || !this.routeId) return;
if (
modelId &&
!this.models.value.find(model => model.id === modelId)?.available
) {
return; return;
} }
this.globalStateService.globalState.set(AI_MODEL_ID_KEY, modelId); this.modelId.value = modelId;
}; this.globalStateService.globalState.set(
this.storageKey(this.workspaceId, this.routeId),
private readonly init = async () => { modelId
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.disposables.push(() => sub.unsubscribe()); }
};
private readonly initModels = async (prompt?: string) => { private async load(workspaceId: string, routeId: string) {
const promptName = prompt || 'Chat With AFFiNE AI'; const requestId = ++this.requestId;
const models = await this.getModelsByPrompt(promptName); const result = await this.gqlService.gql({
if (models) { query: getCopilotRouteOptionsQuery,
const { defaultModel, optionalModels, proModels } = models; variables: { promptName: routeId },
this.models.value = optionalModels.map(model => { });
const [category] = model.name.split(' '); if (requestId !== this.requestId) return;
const version = model.name.slice(category.length + 1); const options = result.currentUser?.copilot?.routeOptions;
return { if (!options) {
name: model.name, this.models.value = [];
id: model.id, this.modelId.value = undefined;
version, return;
category,
isPro: proModels.some(proModel => proModel.id === model.id),
isDefault: model.id === defaultModel,
};
});
} }
}; 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<string>(
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) => { private storageKey(workspaceId: string, routeId: string) {
return this.gqlService return `${ROUTE_TARGET_KEY}:${workspaceId}:${routeId}`;
.gql({ }
query: getPromptModelsQuery,
variables: { promptName },
})
.then(res => res.currentUser?.copilot?.models);
};
} }

View File

@@ -69,10 +69,7 @@ describe('AudioTranscriptionJobStore transcript task API', () => {
.mockResolvedValueOnce({ submitTranscriptTask: { id: 'task-1' } }) .mockResolvedValueOnce({ submitTranscriptTask: { id: 'task-1' } })
.mockResolvedValueOnce({ retryTranscriptTask: { id: 'task-2' } }) .mockResolvedValueOnce({ retryTranscriptTask: { id: 'task-2' } })
.mockResolvedValueOnce({ settleTranscriptTask: { id: 'task-2' } }); .mockResolvedValueOnce({ settleTranscriptTask: { id: 'task-2' } });
const store = createStore(gql, async () => ({ const store = createStore(gql, async () => ({ files: [file] }));
files: [file],
input: { strategy: 'gemini' },
}));
await store.submitTranscriptTask(); await store.submitTranscriptTask();
await store.retryTranscriptTask('task-1'); await store.retryTranscriptTask('task-1');
@@ -87,7 +84,7 @@ describe('AudioTranscriptionJobStore transcript task API', () => {
workspaceId: 'workspace-1', workspaceId: 'workspace-1',
blobId: 'blob-1', blobId: 'blob-1',
blobs: [file], blobs: [file],
input: { strategy: 'gemini' }, input: undefined,
}, },
}) })
); );

View File

@@ -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 { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import { import {
AIDraftService, AIDraftService,
AIModelService,
AIToolsConfigService, AIToolsConfigService,
} from '@affine/core/modules/ai-button'; } 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 { ServerService, SubscriptionService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { FeatureFlagService } from '@affine/core/modules/feature-flag';
@@ -36,8 +36,8 @@ export const AIChatBlockPeekView = ({
const affineWorkspaceDialogService = framework.get(WorkspaceDialogService); const affineWorkspaceDialogService = framework.get(WorkspaceDialogService);
const aiDraftService = framework.get(AIDraftService); const aiDraftService = framework.get(AIDraftService);
const aiToolsConfigService = framework.get(AIToolsConfigService); const aiToolsConfigService = framework.get(AIToolsConfigService);
const subscriptionService = framework.get(SubscriptionService);
const aiModelService = framework.get(AIModelService); const aiModelService = framework.get(AIModelService);
const subscriptionService = framework.get(SubscriptionService);
const handleAISubscribe = useAISubscribe(); const handleAISubscribe = useAISubscribe();
return useMemo(() => { return useMemo(() => {
@@ -52,8 +52,8 @@ export const AIChatBlockPeekView = ({
affineWorkspaceDialogService, affineWorkspaceDialogService,
aiDraftService, aiDraftService,
aiToolsConfigService, aiToolsConfigService,
subscriptionService,
aiModelService, aiModelService,
subscriptionService,
handleAISubscribe handleAISubscribe
); );
return toReactNode(template); return toReactNode(template);
@@ -68,8 +68,8 @@ export const AIChatBlockPeekView = ({
affineWorkspaceDialogService, affineWorkspaceDialogService,
aiDraftService, aiDraftService,
aiToolsConfigService, aiToolsConfigService,
subscriptionService,
aiModelService, aiModelService,
subscriptionService,
handleAISubscribe, handleAISubscribe,
]); ]);
}; };

View File

@@ -1,28 +1,28 @@
{ {
"ar": 92, "ar": 90,
"ca": 89, "ca": 88,
"da": 4, "da": 3,
"de": 98, "de": 98,
"el-GR": 88, "el-GR": 86,
"en": 100, "en": 100,
"es-AR": 88, "es-AR": 87,
"es-CL": 89, "es-CL": 88,
"es": 88, "es": 86,
"fa": 88, "fa": 86,
"fr": 92, "fr": 90,
"hi": 1, "hi": 1,
"it": 89, "it": 88,
"ja": 88, "ja": 86,
"kk": 96, "kk": 93,
"ko": 89, "ko": 87,
"nb-NO": 44, "nb-NO": 43,
"pl": 89, "pl": 88,
"pt-BR": 88, "pt-BR": 86,
"ru": 90, "ru": 88,
"sv-SE": 88, "sv-SE": 87,
"tr": 96, "tr": 93,
"uk": 88, "uk": 86,
"ur": 96, "ur": 93,
"zh-Hans": 100, "zh-Hans": 97,
"zh-Hant": 90 "zh-Hant": 88
} }

View File

@@ -5925,10 +5925,6 @@ export function useAFFiNEI18N(): {
/** /**
* `AI BYOK (Beta)` * `AI BYOK (Beta)`
*/ */
["com.affine.settings.workspace.byok.title-beta"](): string;
/**
* `AI BYOK`
*/
["com.affine.settings.workspace.byok.title"](): string; ["com.affine.settings.workspace.byok.title"](): string;
/** /**
* `Loading provider keys.` * `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.` * `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; ["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` * `Provider keys`
*/ */
@@ -5983,13 +5971,25 @@ export function useAFFiNEI18N(): {
*/ */
["com.affine.settings.workspace.byok.storage.server"](): string; ["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` * `Disabled after failure`
*/ */
@@ -5998,10 +5998,6 @@ export function useAFFiNEI18N(): {
* `Key verified` * `Key verified`
*/ */
["com.affine.settings.workspace.byok.status.key-verified"](): string; ["com.affine.settings.workspace.byok.status.key-verified"](): string;
/**
* `Key test failed`
*/
["com.affine.settings.workspace.byok.status.key-test-failed"](): string;
/** /**
* `Text` * `Text`
*/ */
@@ -6032,16 +6028,6 @@ export function useAFFiNEI18N(): {
["com.affine.settings.workspace.byok.row.activity.failed"](options: { ["com.affine.settings.workspace.byok.row.activity.failed"](options: {
readonly date: string; readonly date: string;
}): 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` * `not used yet`
*/ */
@@ -6113,17 +6099,69 @@ export function useAFFiNEI18N(): {
readonly count: string; readonly count: string;
}): 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` * `Provider`
*/ */
@@ -6137,9 +6175,9 @@ export function useAFFiNEI18N(): {
*/ */
["com.affine.settings.workspace.byok.field.description"](): string; ["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` * `API key`
*/ */
@@ -6148,6 +6186,10 @@ export function useAFFiNEI18N(): {
* `Endpoint` * `Endpoint`
*/ */
["com.affine.settings.workspace.byok.field.endpoint"](): string; ["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.` * `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; ["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` * `Add key`
*/ */
["com.affine.settings.workspace.byok.action.add-key"](): string; ["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` * `Cancel`
*/ */
["com.affine.settings.workspace.byok.action.cancel"](): string; ["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` * `Clear all BYOK keys`
*/ */
@@ -6228,6 +6417,14 @@ export function useAFFiNEI18N(): {
* `BYOK keys not cleared` * `BYOK keys not cleared`
*/ */
["com.affine.settings.workspace.byok.notify.clear-failed.title"](): string; ["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.` * `Please try again.`
*/ */

View File

@@ -1480,25 +1480,19 @@
"com.affine.settings.workspace": "Workspace", "com.affine.settings.workspace": "Workspace",
"com.affine.settings.workspace.description": "Hier kannst du die Informationen zum aktuellen Workspace anzeigen.", "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-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.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.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.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.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.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.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.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.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.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.local": "Lokal",
"com.affine.settings.workspace.byok.storage.server": "Server", "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.disabled-after-failure": "Nach Fehler deaktiviert",
"com.affine.settings.workspace.byok.status.key-verified": "Schlüssel verifiziert", "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.text": "Text",
"com.affine.settings.workspace.byok.capability.image-input": "Bildeingabe", "com.affine.settings.workspace.byok.capability.image-input": "Bildeingabe",
"com.affine.settings.workspace.byok.capability.actions": "Aktionen", "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.transcript": "Transkript",
"com.affine.settings.workspace.byok.capability.indexing": "Indizierung", "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.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.row.activity.unused": "noch nicht verwendet",
"com.affine.settings.workspace.byok.coverage.title": "Feature-Abdeckung", "com.affine.settings.workspace.byok.coverage.title": "Feature-Abdeckung",
"com.affine.settings.workspace.byok.feature.chat.title": "Schreiben und Chatten", "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.title": "BYOK-Nutzung",
"com.affine.settings.workspace.byok.usage.period": "Letzte 30 Tage", "com.affine.settings.workspace.byok.usage.period": "Letzte 30 Tage",
"com.affine.settings.workspace.byok.usage.tokens": "{{count}} Token", "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.provider": "Provider",
"com.affine.settings.workspace.byok.field.key-name": "Schlüsselname", "com.affine.settings.workspace.byok.field.key-name": "Schlüsselname",
"com.affine.settings.workspace.byok.field.description": "Beschreibung", "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.api-key": "API-Key",
"com.affine.settings.workspace.byok.field.endpoint": "Endpunkt", "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.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.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.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.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.clear-all": "Alle BYOK-Schlüssel löschen",
"com.affine.settings.workspace.byok.action.reorder": "Umsortieren", "com.affine.settings.workspace.byok.action.reorder": "Umsortieren",
"com.affine.settings.workspace.byok.action.edit": "Bearbeiten", "com.affine.settings.workspace.byok.action.edit": "Bearbeiten",

View File

@@ -1479,26 +1479,25 @@
"com.affine.settings.meetings.record.permission-modal.open-setting": "Open System Settings", "com.affine.settings.meetings.record.permission-modal.open-setting": "Open System Settings",
"com.affine.settings.workspace": "Workspace", "com.affine.settings.workspace": "Workspace",
"com.affine.settings.workspace.description": "You can view current workspace's information here.", "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 (Beta)",
"com.affine.settings.workspace.byok.title": "AI BYOK",
"com.affine.settings.workspace.byok.loading": "Loading provider keys.", "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.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.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.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.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.title": "Provider keys",
"com.affine.settings.workspace.byok.keys.description": "List order controls fallback within each storage group.", "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.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.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.local": "Local",
"com.affine.settings.workspace.byok.storage.server": "Server", "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.server.description": "Available to workspace members.",
"com.affine.settings.workspace.byok.storage.local-desktop-only": "Local (Desktop only)", "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.disabled-after-failure": "Disabled after failure",
"com.affine.settings.workspace.byok.status.key-verified": "Key verified", "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.text": "Text",
"com.affine.settings.workspace.byok.capability.image-input": "Image input", "com.affine.settings.workspace.byok.capability.image-input": "Image input",
"com.affine.settings.workspace.byok.capability.actions": "Actions", "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.transcript": "Transcript",
"com.affine.settings.workspace.byok.capability.indexing": "Indexing", "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.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.row.activity.unused": "not used yet",
"com.affine.settings.workspace.byok.coverage.title": "Feature coverage", "com.affine.settings.workspace.byok.coverage.title": "Feature coverage",
"com.affine.settings.workspace.byok.feature.chat.title": "Writing and chat", "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.title": "BYOK usage",
"com.affine.settings.workspace.byok.usage.period": "Last 30 days", "com.affine.settings.workspace.byok.usage.period": "Last 30 days",
"com.affine.settings.workspace.byok.usage.tokens": "{{count}} tokens", "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.connect-title": "Connect AI provider",
"com.affine.settings.workspace.byok.modal.edit-title": "Edit provider key", "com.affine.settings.workspace.byok.modal.manage-title": "Manage provider",
"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-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.provider": "Provider",
"com.affine.settings.workspace.byok.field.key-name": "Key name", "com.affine.settings.workspace.byok.field.key-name": "Key name",
"com.affine.settings.workspace.byok.field.description": "Description", "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.api-key": "API key",
"com.affine.settings.workspace.byok.field.endpoint": "Endpoint", "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.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.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.keep-current-key": "Leave blank to keep the current key",
"com.affine.settings.workspace.byok.placeholder.description": "Workspace fallback 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.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.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.clear-all": "Clear all BYOK keys",
"com.affine.settings.workspace.byok.action.reorder": "Reorder", "com.affine.settings.workspace.byok.action.reorder": "Reorder",
"com.affine.settings.workspace.byok.action.edit": "Edit", "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.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.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.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.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.title": "Cannot reorder across storage",
"com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "Local keys and server keys keep separate fallback order.", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "Local keys and server keys keep separate fallback order.",

View File

@@ -1439,25 +1439,19 @@
"com.affine.settings.workspace": "Жұмыс кеңістігі", "com.affine.settings.workspace": "Жұмыс кеңістігі",
"com.affine.settings.workspace.description": "Ағымдағы жұмыс кеңістігінің ақпаратын осы жерден көре аласың.", "com.affine.settings.workspace.description": "Ағымдағы жұмыс кеңістігінің ақпаратын осы жерден көре аласың.",
"com.affine.settings.workspace.byok.title-beta": "AI BYOK (Бета)", "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.loading": "Провайдер кілттері жүктелуде.",
"com.affine.settings.workspace.byok.subtitle": "Бұл жұмыс кеңістігі үшін өз провайдер кілттеріңді пайдалан.", "com.affine.settings.workspace.byok.subtitle": "Бұл жұмыс кеңістігі үшін өз провайдер кілттеріңді пайдалан.",
"com.affine.settings.workspace.byok.header": "AFFiNE AI жоспар бағыттарына дейін жұмыс кеңістігінің провайдер кілттерін пайдалан.", "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.title": "BYOK үшін Pro, Team немесе Believer қажет",
"com.affine.settings.workspace.byok.locked.description": "Провайдер кілттерін қосу және AFFiNE AI-ды өзіңнің OpenAI, Anthropic, Gemini немесе FAL аккаунтың арқылы бағыттау үшін осы жұмыс кеңістігін жаңарт.", "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.title": "Провайдер кілттері",
"com.affine.settings.workspace.byok.keys.description": "Тізім реті әр сақтау тобы ішінде резервтік ретті басқарады.", "com.affine.settings.workspace.byok.keys.description": "Тізім реті әр сақтау тобы ішінде резервтік ретті басқарады.",
"com.affine.settings.workspace.byok.empty.title": "Провайдер кілттері жоқ", "com.affine.settings.workspace.byok.empty.title": "Провайдер кілттері жоқ",
"com.affine.settings.workspace.byok.empty.description": "Осы жұмыс кеңістігі үшін бірінші бағытты жасау үшін кілт қос. Кілт болмайынша провайдер жолдары көрсетілмейді.", "com.affine.settings.workspace.byok.empty.description": "Осы жұмыс кеңістігі үшін бірінші бағытты жасау үшін кілт қос. Кілт болмайынша провайдер жолдары көрсетілмейді.",
"com.affine.settings.workspace.byok.storage.local": "Жергілікті", "com.affine.settings.workspace.byok.storage.local": "Жергілікті",
"com.affine.settings.workspace.byok.storage.server": "Сервер", "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.disabled-after-failure": "Сәтсіздіктен кейін өшірілді",
"com.affine.settings.workspace.byok.status.key-verified": "Кілт расталды", "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.text": "Мәтін",
"com.affine.settings.workspace.byok.capability.image-input": "Сурет енгізу", "com.affine.settings.workspace.byok.capability.image-input": "Сурет енгізу",
"com.affine.settings.workspace.byok.capability.actions": "Әрекеттер", "com.affine.settings.workspace.byok.capability.actions": "Әрекеттер",
@@ -1465,8 +1459,6 @@
"com.affine.settings.workspace.byok.capability.transcript": "Транскрипт", "com.affine.settings.workspace.byok.capability.transcript": "Транскрипт",
"com.affine.settings.workspace.byok.capability.indexing": "Индекстеу", "com.affine.settings.workspace.byok.capability.indexing": "Индекстеу",
"com.affine.settings.workspace.byok.row.activity.failed": "{{date}} күні сәтсіз аяқталды", "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.row.activity.unused": "әлі қолданылмаған",
"com.affine.settings.workspace.byok.coverage.title": "Функцияларды қамту", "com.affine.settings.workspace.byok.coverage.title": "Функцияларды қамту",
"com.affine.settings.workspace.byok.feature.chat.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.title": "BYOK қолданылуы",
"com.affine.settings.workspace.byok.usage.period": "Соңғы 30 күн", "com.affine.settings.workspace.byok.usage.period": "Соңғы 30 күн",
"com.affine.settings.workspace.byok.usage.tokens": "{{count}} токен", "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.provider": "Провайдер",
"com.affine.settings.workspace.byok.field.key-name": "Кілт атауы", "com.affine.settings.workspace.byok.field.key-name": "Кілт атауы",
"com.affine.settings.workspace.byok.field.description": "Сипаттама", "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.api-key": "API кілті",
"com.affine.settings.workspace.byok.field.endpoint": "Эндпойнт", "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.add-key": "Кілтті қос",
"com.affine.settings.workspace.byok.action.test-key": "Кілтті тексер",
"com.affine.settings.workspace.byok.action.cancel": "Бас тарт", "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.clear-all": "Барлық BYOK кілттерін тазарт",
"com.affine.settings.workspace.byok.action.reorder": "Ретін өзгерт", "com.affine.settings.workspace.byok.action.reorder": "Ретін өзгерт",
"com.affine.settings.workspace.byok.action.edit": "Өңде", "com.affine.settings.workspace.byok.action.edit": "Өңде",

View File

@@ -1439,25 +1439,19 @@
"com.affine.settings.workspace": "Çalışma alanı", "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.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-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.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.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.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.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.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.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.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.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.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.local": "Yerel",
"com.affine.settings.workspace.byok.storage.server": "Sunucu", "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.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-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.text": "Metin",
"com.affine.settings.workspace.byok.capability.image-input": "Görüntü girişi", "com.affine.settings.workspace.byok.capability.image-input": "Görüntü girişi",
"com.affine.settings.workspace.byok.capability.actions": "Eylemler", "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.transcript": "Deşifre metni",
"com.affine.settings.workspace.byok.capability.indexing": "İndeksleme", "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.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.row.activity.unused": "henüz kullanılmadı",
"com.affine.settings.workspace.byok.coverage.title": "Özellik kapsamı", "com.affine.settings.workspace.byok.coverage.title": "Özellik kapsamı",
"com.affine.settings.workspace.byok.feature.chat.title": "Yazma ve sohbet", "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.title": "BYOK kullanımı",
"com.affine.settings.workspace.byok.usage.period": "Son 30 gün", "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.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.provider": "Sağlayıcı",
"com.affine.settings.workspace.byok.field.key-name": "Anahtar adı", "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.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.api-key": "API anahtarı",
"com.affine.settings.workspace.byok.field.endpoint": "Uç nokta", "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.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.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.clear-all": "Tüm BYOK anahtarlarını temizle",
"com.affine.settings.workspace.byok.action.reorder": "Yeniden sırala", "com.affine.settings.workspace.byok.action.reorder": "Yeniden sırala",
"com.affine.settings.workspace.byok.action.edit": "Düzenle", "com.affine.settings.workspace.byok.action.edit": "Düzenle",

View File

@@ -1735,8 +1735,6 @@
"com.affine.settings.workspace.byok.action.delete": "حذف کریں۔", "com.affine.settings.workspace.byok.action.delete": "حذف کریں۔",
"com.affine.settings.workspace.byok.action.edit": "ترمیم کریں۔", "com.affine.settings.workspace.byok.action.edit": "ترمیم کریں۔",
"com.affine.settings.workspace.byok.action.reorder": "دوبارہ ترتیب دیں۔", "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.actions": "اعمال",
"com.affine.settings.workspace.byok.capability.image-generate": "تصویر بنائیں", "com.affine.settings.workspace.byok.capability.image-generate": "تصویر بنائیں",
"com.affine.settings.workspace.byok.capability.image-input": "امیج ان پٹ", "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.endpoint": "اختتامی نقطہ",
"com.affine.settings.workspace.byok.field.key-name": "کلیدی نام", "com.affine.settings.workspace.byok.field.key-name": "کلیدی نام",
"com.affine.settings.workspace.byok.field.provider": "فراہم کرنے والا", "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.header": "AFFiNE AI پلان کے راستوں سے پہلے ورک اسپیس فراہم کنندہ کیز استعمال کریں۔",
"com.affine.settings.workspace.byok.keys.description": "فہرست آرڈر کنٹرولز فال بیک ہر اسٹوریج گروپ کے اندر۔", "com.affine.settings.workspace.byok.keys.description": "فہرست آرڈر کنٹرولز فال بیک ہر اسٹوریج گروپ کے اندر۔",
"com.affine.settings.workspace.byok.keys.title": "فراہم کنندہ کی چابیاں", "com.affine.settings.workspace.byok.keys.title": "فراہم کنندہ کی چابیاں",
"com.affine.settings.workspace.byok.loading": "فراہم کنندہ کی چابیاں لوڈ ہو رہی ہیں۔", "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.description": "فراہم کنندہ کیز شامل کرنے کے لیے اس ورک اسپیس کو اپ گریڈ کریں اور AFFiNE AI کو اپنے OpenAI، Anthropic، Gemini، یا FAL اکاؤنٹ کے ذریعے روٹ کریں۔",
"com.affine.settings.workspace.byok.locked.title": "BYOK کو پرو، ٹیم، یا مومن درکار ہے۔", "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.clear-failed.title": "BYOK کیز صاف نہیں ہوئیں",
"com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "لوکل کیز اور سرور کیز الگ الگ فال بیک آرڈر رکھتی ہیں۔", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "لوکل کیز اور سرور کیز الگ الگ فال بیک آرڈر رکھتی ہیں۔",
"com.affine.settings.workspace.byok.notify.cross-storage-reorder.title": "پورے اسٹوریج میں دوبارہ ترتیب نہیں دی جا سکتی", "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.reorder-failed.title": "BYOK کیز دوبارہ ترتیب نہیں دی گئیں۔",
"com.affine.settings.workspace.byok.notify.save-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.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.failed": "ناکام {{date}}",
"com.affine.settings.workspace.byok.row.activity.unused": "ابھی تک استعمال نہیں کیا", "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.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.status.key-verified": "کلید کی تصدیق ہو گئی۔",
"com.affine.settings.workspace.byok.storage.local": "مقامی", "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.storage.server": "سرور",
"com.affine.settings.workspace.byok.subtitle": "اس ورک اسپیس کے لیے اپنی فراہم کنندہ کیز استعمال کریں۔", "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.title-beta": "AI BYOK (بیٹا)",
"com.affine.settings.workspace.byok.usage.period": "آخری 30 دن", "com.affine.settings.workspace.byok.usage.period": "آخری 30 دن",
"com.affine.settings.workspace.byok.usage.title": "BYOK کا استعمال", "com.affine.settings.workspace.byok.usage.title": "BYOK کا استعمال",

View File

@@ -1478,25 +1478,19 @@
"com.affine.settings.workspace": "工作区", "com.affine.settings.workspace": "工作区",
"com.affine.settings.workspace.description": "您可以在此处自定义您的工作区。", "com.affine.settings.workspace.description": "您可以在此处自定义您的工作区。",
"com.affine.settings.workspace.byok.title-beta": "AI BYOK(Beta)", "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.loading": "正在加载服务商密钥。",
"com.affine.settings.workspace.byok.subtitle": "为此工作区使用您自己的服务商密钥。", "com.affine.settings.workspace.byok.subtitle": "为此工作区使用您自己的服务商密钥。",
"com.affine.settings.workspace.byok.header": "优先使用工作区服务商密钥,再回退到 AFFiNE AI 套餐通道。", "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.title": "BYOK 需要 Pro、Team 或 Believer",
"com.affine.settings.workspace.byok.locked.description": "升级此工作区后,可添加服务商密钥,并通过您自己的 OpenAI、Anthropic、Gemini 或 FAL 账号路由 AFFiNE AI。", "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.title": "服务商密钥",
"com.affine.settings.workspace.byok.keys.description": "列表顺序控制每个存储分组内的回退顺序。", "com.affine.settings.workspace.byok.keys.description": "列表顺序控制每个存储分组内的回退顺序。",
"com.affine.settings.workspace.byok.empty.title": "暂无服务商密钥", "com.affine.settings.workspace.byok.empty.title": "暂无服务商密钥",
"com.affine.settings.workspace.byok.empty.description": "添加密钥后,将为此工作区创建第一条路由。创建前不会显示服务商行。", "com.affine.settings.workspace.byok.empty.description": "添加密钥后,将为此工作区创建第一条路由。创建前不会显示服务商行。",
"com.affine.settings.workspace.byok.storage.local": "本地", "com.affine.settings.workspace.byok.storage.local": "本地",
"com.affine.settings.workspace.byok.storage.server": "服务端", "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.disabled-after-failure": "失败后已禁用",
"com.affine.settings.workspace.byok.status.key-verified": "密钥已验证", "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.text": "文本",
"com.affine.settings.workspace.byok.capability.image-input": "图片输入", "com.affine.settings.workspace.byok.capability.image-input": "图片输入",
"com.affine.settings.workspace.byok.capability.actions": "动作", "com.affine.settings.workspace.byok.capability.actions": "动作",
@@ -1504,8 +1498,6 @@
"com.affine.settings.workspace.byok.capability.transcript": "转录", "com.affine.settings.workspace.byok.capability.transcript": "转录",
"com.affine.settings.workspace.byok.capability.indexing": "索引", "com.affine.settings.workspace.byok.capability.indexing": "索引",
"com.affine.settings.workspace.byok.row.activity.failed": "{{date}} 失败", "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.row.activity.unused": "尚未使用",
"com.affine.settings.workspace.byok.coverage.title": "功能覆盖", "com.affine.settings.workspace.byok.coverage.title": "功能覆盖",
"com.affine.settings.workspace.byok.feature.chat.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.title": "BYOK 用量",
"com.affine.settings.workspace.byok.usage.period": "最近 30 天", "com.affine.settings.workspace.byok.usage.period": "最近 30 天",
"com.affine.settings.workspace.byok.usage.tokens": "{{count}} tokens", "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.provider": "服务商",
"com.affine.settings.workspace.byok.field.key-name": "密钥名称", "com.affine.settings.workspace.byok.field.key-name": "密钥名称",
"com.affine.settings.workspace.byok.field.description": "描述", "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.api-key": "API key",
"com.affine.settings.workspace.byok.field.endpoint": "Endpoint", "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.custom-disabled": "服务器管理员已禁用自定义端点。请在自托管管理后台启用 copilot.byok.allowCustomEndpoint。",
"com.affine.settings.workspace.byok.endpoint.private-disabled": "私有网络端点还需要服务器管理员启用 copilot.byok.allowPrivateEndpoint。", "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.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.cancel": "取消",
"com.affine.settings.workspace.byok.action.save-key": "保存密钥",
"com.affine.settings.workspace.byok.action.clear-all": "清空所有 BYOK 密钥", "com.affine.settings.workspace.byok.action.clear-all": "清空所有 BYOK 密钥",
"com.affine.settings.workspace.byok.action.reorder": "重新排序", "com.affine.settings.workspace.byok.action.reorder": "重新排序",
"com.affine.settings.workspace.byok.action.edit": "编辑", "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.delete-failed.title": "BYOK 密钥未删除",
"com.affine.settings.workspace.byok.notify.reorder-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.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.operation-failed.message": "请稍后重试。",
"com.affine.settings.workspace.byok.notify.cross-storage-reorder.title": "不能跨存储排序", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.title": "不能跨存储排序",
"com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "本地密钥和服务端密钥会保留各自独立的回退顺序。", "com.affine.settings.workspace.byok.notify.cross-storage-reorder.message": "本地密钥和服务端密钥会保留各自独立的回退顺序。",

View File

@@ -29,9 +29,9 @@ const config: PlaywrightTestConfig = {
webServer: [ webServer: [
{ {
command: 'yarn run -T affine dev -p @affine/web', command: 'yarn run -T affine dev -p @affine/web',
stdout: 'ignore', stdout: 'pipe',
stderr: 'ignore', stderr: 'pipe',
timeout: 120 * 1000, timeout: 240 * 1000,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
env: { env: {
COVERAGE: process.env.COVERAGE || 'false', COVERAGE: process.env.COVERAGE || 'false',
@@ -40,10 +40,10 @@ const config: PlaywrightTestConfig = {
}, },
{ {
command: 'yarn run -T affine dev -p @affine/server', command: 'yarn run -T affine dev -p @affine/server',
timeout: 120 * 1000, timeout: 240 * 1000,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
stdout: 'ignore', stdout: 'pipe',
stderr: 'ignore', stderr: 'pipe',
env: { env: {
DATABASE_URL: DATABASE_URL:
process.env.DATABASE_URL ?? process.env.DATABASE_URL ??