mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add poem MCP server as optional service endpoint
This commit is contained in:
@@ -13,10 +13,7 @@ use fjall::{
|
||||
CompressionType, Database, Keyspace, KeyspaceCreateOptions, KvSeparationOptions,
|
||||
};
|
||||
use mail_parser::MessageParser;
|
||||
use tantivy::{
|
||||
indexer::{LogMergePolicy, NoMergePolicy},
|
||||
Index, IndexWriter, TantivyDocument,
|
||||
};
|
||||
use tantivy::{indexer::NoMergePolicy, Index, IndexWriter, TantivyDocument};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
|
||||
@@ -250,6 +250,14 @@ pub struct Settings {
|
||||
)]
|
||||
pub bichon_enable_smtp: bool,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
default_value = "false",
|
||||
help = "Enable the MCP (Model Context Protocol) server for AI assistant integration"
|
||||
)]
|
||||
pub bichon_enable_mcp: bool,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct SystemConfigurations {
|
||||
pub bichon_index_dir: Option<String>,
|
||||
pub bichon_data_dir: Option<String>,
|
||||
|
||||
pub bichon_enable_mcp: bool,
|
||||
pub bichon_enable_smtp: bool,
|
||||
pub bichon_smtp_port: u16,
|
||||
pub bichon_smtp_encryption: String,
|
||||
@@ -88,6 +89,7 @@ impl From<&Settings> for SystemConfigurations {
|
||||
bichon_base_url: s.bichon_base_url.clone(),
|
||||
bichon_index_dir: s.bichon_index_dir.clone(),
|
||||
bichon_data_dir: s.bichon_data_dir.clone(),
|
||||
bichon_enable_mcp: s.bichon_enable_mcp,
|
||||
bichon_enable_smtp: s.bichon_enable_smtp,
|
||||
bichon_smtp_port: s.bichon_smtp_port,
|
||||
bichon_smtp_encryption: s.bichon_smtp_encryption.to_string(),
|
||||
|
||||
@@ -30,6 +30,8 @@ tokio.workspace = true
|
||||
http.workspace = true
|
||||
urlencoding.workspace = true
|
||||
mimalloc.workspace = true
|
||||
poem-mcpserver = { version = "0.3", features = ["streamable-http"] }
|
||||
schemars = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
poem = { version = "3.1.12", features = ["test"] }
|
||||
|
||||
@@ -46,6 +46,7 @@ use crate::rest::start_http_server;
|
||||
|
||||
pub mod common;
|
||||
pub mod error;
|
||||
pub mod mcp;
|
||||
pub mod rest;
|
||||
|
||||
#[global_allocator]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use bichon_core::common::auth::ClientContext;
|
||||
use poem::Request;
|
||||
use poem_mcpserver::{streamable_http, McpServer};
|
||||
|
||||
mod tools;
|
||||
use tools::BichonMcpTools;
|
||||
|
||||
/// Create a Poem endpoint that handles MCP Streamable HTTP requests.
|
||||
///
|
||||
/// The endpoint requires authentication via `ApiGuard` middleware (applied at
|
||||
/// the route level in `rest/mod.rs`). The guard stores a `ClientContext` in
|
||||
/// request extensions, which the server factory reads to configure per-session
|
||||
/// tool authorization.
|
||||
pub fn mcp_endpoint() -> impl poem::IntoEndpoint {
|
||||
streamable_http::endpoint(|req: &Request| {
|
||||
let ctx = req
|
||||
.extensions()
|
||||
.get::<ClientContext>()
|
||||
.expect("ApiGuard middleware must provide ClientContext in request extensions")
|
||||
.clone();
|
||||
McpServer::new()
|
||||
.with_server_info("bichon-mcp", env!("CARGO_PKG_VERSION"))
|
||||
.tools(BichonMcpTools::new(ctx))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bichon_core::{
|
||||
account::migration::AccountModel,
|
||||
common::auth::ClientContext,
|
||||
dashboard::DashboardStats,
|
||||
message::search::{
|
||||
search_attachment_impl, search_messages_impl, AttachmentSearchFilter, AttachmentSearchRequest,
|
||||
EmailSearchFilter, EmailSearchRequest, SortBy,
|
||||
},
|
||||
message::{content::retrieve_email_content, list::get_thread_messages},
|
||||
users::permissions::Permission,
|
||||
};
|
||||
use poem_mcpserver::{content::Text, Tools};
|
||||
|
||||
/// Tools for interacting with the Bichon email archive via MCP.
|
||||
///
|
||||
/// Provides email search, content retrieval, thread viewing, attachment search,
|
||||
/// dashboard statistics, and account management capabilities.
|
||||
pub struct BichonMcpTools {
|
||||
context: ClientContext,
|
||||
}
|
||||
|
||||
impl BichonMcpTools {
|
||||
pub fn new(context: ClientContext) -> Self {
|
||||
Self { context }
|
||||
}
|
||||
|
||||
/// Derive the set of account IDs that the current user is authorized to access.
|
||||
/// Returns None if the user has global access (DATA_READ_ALL or ACCOUNT_MANAGE_ALL),
|
||||
/// meaning "all accounts are accessible".
|
||||
fn authorized_accounts(&self) -> Option<HashSet<u64>> {
|
||||
if self.context.has_permission(None, Permission::DATA_READ_ALL)
|
||||
|| self.context.has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.context
|
||||
.user
|
||||
.account_access_map
|
||||
.keys()
|
||||
.copied()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict the given account_ids to only those the user is authorized to access.
|
||||
/// If the user has global access, the input is returned as-is.
|
||||
fn restrict_account_ids(
|
||||
&self,
|
||||
account_ids: Option<Vec<u64>>,
|
||||
) -> Option<HashSet<u64>> {
|
||||
let global = self.authorized_accounts();
|
||||
match (global, account_ids) {
|
||||
// User has global access → return input as HashSet (or None)
|
||||
(None, Some(ids)) => Some(ids.into_iter().collect()),
|
||||
(None, None) => None,
|
||||
// User is scoped → intersect with authorized set
|
||||
(Some(authorized), Some(ids)) => {
|
||||
let filtered: HashSet<u64> =
|
||||
ids.into_iter().filter(|id| authorized.contains(id)).collect();
|
||||
if filtered.is_empty() {
|
||||
Some(filtered)
|
||||
} else {
|
||||
Some(filtered)
|
||||
}
|
||||
}
|
||||
(Some(authorized), None) => Some(authorized),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[Tools]
|
||||
impl BichonMcpTools {
|
||||
/// Search archived emails using full-text and structured filters.
|
||||
async fn search_emails(
|
||||
&self,
|
||||
/// Full-text search across all message fields (subject, body, from, to, etc.)
|
||||
text: Option<String>,
|
||||
/// Filter by email subject line
|
||||
subject: Option<String>,
|
||||
/// Filter by sender email address
|
||||
from: Option<String>,
|
||||
/// Filter by recipient email address
|
||||
to: Option<String>,
|
||||
/// Start date as Unix timestamp in milliseconds
|
||||
since: Option<i64>,
|
||||
/// End date as Unix timestamp in milliseconds
|
||||
before: Option<i64>,
|
||||
/// Filter by specific account IDs
|
||||
account_ids: Option<Vec<u64>>,
|
||||
/// Filter by specific mailbox/folder IDs
|
||||
mailbox_ids: Option<Vec<u64>>,
|
||||
/// Only return messages that have attachments
|
||||
has_attachment: Option<bool>,
|
||||
/// Filter by attachment filename
|
||||
attachment_name: Option<String>,
|
||||
/// Filter by tags/labels
|
||||
tags: Option<Vec<String>>,
|
||||
/// Page number (1-based, default 1)
|
||||
page: Option<u64>,
|
||||
/// Results per page (default 50, max 500)
|
||||
page_size: Option<u64>,
|
||||
) -> Result<Text<String>, String> {
|
||||
let authorized_ids = self.restrict_account_ids(account_ids);
|
||||
|
||||
let filter = EmailSearchFilter {
|
||||
text,
|
||||
subject,
|
||||
from,
|
||||
to,
|
||||
since,
|
||||
before,
|
||||
account_ids: authorized_ids.clone(),
|
||||
mailbox_ids: mailbox_ids.map(|v| v.into_iter().collect()),
|
||||
has_attachment,
|
||||
attachment_name,
|
||||
tags: tags.map(|v| v.into_iter().collect()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let request = EmailSearchRequest {
|
||||
filter,
|
||||
page: page.unwrap_or(1),
|
||||
page_size: page_size.unwrap_or(50).min(500),
|
||||
sort_by: Some(SortBy::DATE),
|
||||
desc: Some(true),
|
||||
};
|
||||
|
||||
search_messages_impl(authorized_ids, request)
|
||||
.map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default()))
|
||||
.map_err(|e| format!("Search failed: {:#}", e))
|
||||
}
|
||||
|
||||
/// Retrieve the full content of a specific email including plain text, HTML body,
|
||||
/// and attachment metadata (filenames, sizes, content hashes).
|
||||
async fn get_email_content(
|
||||
&self,
|
||||
/// The ID of the email account
|
||||
account_id: u64,
|
||||
/// The envelope ID of the email message
|
||||
envelope_id: String,
|
||||
) -> Result<Text<String>, String> {
|
||||
self.context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.map_err(|e| format!("Permission denied: {:#}", e))?;
|
||||
|
||||
retrieve_email_content(account_id, envelope_id)
|
||||
.map(|content| Text(serde_json::to_string_pretty(&content).unwrap_or_default()))
|
||||
.map_err(|e| format!("Failed to retrieve email content: {:#}", e))
|
||||
}
|
||||
|
||||
/// Retrieve all messages in a specific email thread/conversation.
|
||||
async fn get_thread(
|
||||
&self,
|
||||
/// The ID of the email account
|
||||
account_id: u64,
|
||||
/// The thread ID (from the thread_id field of an envelope)
|
||||
thread_id: String,
|
||||
/// Page number (1-based, default 1)
|
||||
page: Option<u64>,
|
||||
/// Results per page (default 50, max 500)
|
||||
page_size: Option<u64>,
|
||||
) -> Result<Text<String>, String> {
|
||||
self.context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.map_err(|e| format!("Permission denied: {:#}", e))?;
|
||||
|
||||
get_thread_messages(
|
||||
account_id,
|
||||
&thread_id,
|
||||
page.unwrap_or(1),
|
||||
page_size.unwrap_or(50).min(500),
|
||||
)
|
||||
.map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default()))
|
||||
.map_err(|e| format!("Failed to retrieve thread: {:#}", e))
|
||||
}
|
||||
|
||||
/// Search email attachments using filters like filename, extension, content type,
|
||||
/// category, sender, and date range.
|
||||
async fn search_attachments(
|
||||
&self,
|
||||
/// Full-text search in attachment content and metadata
|
||||
text: Option<String>,
|
||||
/// Filter by attachment filename
|
||||
attachment_name: Option<String>,
|
||||
/// Filter by file extension (e.g., pdf, docx, jpg)
|
||||
attachment_extension: Option<String>,
|
||||
/// Filter by category (document, image, spreadsheet, etc.)
|
||||
attachment_category: Option<String>,
|
||||
/// Filter by MIME content type (e.g., application/pdf)
|
||||
attachment_content_type: Option<String>,
|
||||
/// Filter by sender email address
|
||||
from: Option<String>,
|
||||
/// Start date as Unix timestamp in milliseconds
|
||||
since: Option<i64>,
|
||||
/// End date as Unix timestamp in milliseconds
|
||||
before: Option<i64>,
|
||||
/// Filter by specific account IDs
|
||||
account_ids: Option<Vec<u64>>,
|
||||
/// Minimum attachment size in bytes
|
||||
min_size: Option<u64>,
|
||||
/// Maximum attachment size in bytes
|
||||
max_size: Option<u64>,
|
||||
/// Page number (1-based, default 1)
|
||||
page: Option<u64>,
|
||||
/// Results per page (default 50, max 500)
|
||||
page_size: Option<u64>,
|
||||
) -> Result<Text<String>, String> {
|
||||
let authorized_ids = self.restrict_account_ids(account_ids);
|
||||
|
||||
let filter = AttachmentSearchFilter {
|
||||
text,
|
||||
attachment_name,
|
||||
attachment_extension,
|
||||
attachment_category,
|
||||
attachment_content_type,
|
||||
from,
|
||||
since,
|
||||
before,
|
||||
account_ids: authorized_ids.clone(),
|
||||
min_size,
|
||||
max_size,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let request: AttachmentSearchRequest =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"filter": filter,
|
||||
"page": page.unwrap_or(1),
|
||||
"page_size": page_size.unwrap_or(50).min(500),
|
||||
"sort_by": "DATE",
|
||||
"desc": true,
|
||||
}))
|
||||
.map_err(|e| format!("Invalid request: {e}"))?;
|
||||
|
||||
search_attachment_impl(authorized_ids, request)
|
||||
.map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default()))
|
||||
.map_err(|e| format!("Attachment search failed: {:#}", e))
|
||||
}
|
||||
|
||||
/// Retrieve summary statistics about the email archive including total emails,
|
||||
/// attachments, storage usage, top senders, and recent activity.
|
||||
async fn get_dashboard_stats(&self) -> Result<Text<String>, String> {
|
||||
if !self.context.has_permission(None, Permission::SYSTEM_ACCESS) {
|
||||
return Err("Permission denied: requires system:access".into());
|
||||
}
|
||||
|
||||
DashboardStats::get(self.context.clone())
|
||||
.await
|
||||
.map(|stats| Text(serde_json::to_string_pretty(&stats).unwrap_or_default()))
|
||||
.map_err(|e| format!("Failed to retrieve dashboard stats: {:#}", e))
|
||||
}
|
||||
|
||||
/// List all email accounts the current user has access to.
|
||||
/// Returns minimal account information (ID and email address).
|
||||
async fn list_accounts(&self) -> Result<Text<String>, String> {
|
||||
let accounts = AccountModel::minimal_list(false)
|
||||
.map_err(|e| format!("Failed to list accounts: {:#}", e))?;
|
||||
|
||||
let global_access = self
|
||||
.context
|
||||
.has_permission(None, Permission::ACCOUNT_MANAGE_ALL);
|
||||
|
||||
let visible: Vec<_> = if global_access {
|
||||
accounts
|
||||
} else {
|
||||
let authorized: HashSet<u64> =
|
||||
self.context.user.account_access_map.keys().copied().collect();
|
||||
accounts
|
||||
.into_iter()
|
||||
.filter(|a| authorized.contains(&a.id))
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(Text(
|
||||
serde_json::to_string_pretty(&visible).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Retrieve detailed configuration and status of a specific email account.
|
||||
async fn get_account(
|
||||
&self,
|
||||
/// The ID of the email account
|
||||
account_id: u64,
|
||||
) -> Result<Text<String>, String> {
|
||||
self.context
|
||||
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
|
||||
.map_err(|e| format!("Permission denied: {:#}", e))?;
|
||||
|
||||
let account = AccountModel::get(account_id)
|
||||
.map_err(|e| format!("Account not found: {:#}", e))?;
|
||||
|
||||
Ok(Text(
|
||||
serde_json::to_string_pretty(&account).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use bichon_core::settings::cli::SETTINGS;
|
||||
use super::error::ApiErrorResponse;
|
||||
use crate::common::auth::ApiGuard;
|
||||
use crate::common::timeout::{Timeout, TIMEOUT_HEADER};
|
||||
use crate::mcp::mcp_endpoint;
|
||||
use api::create_openapi_service;
|
||||
use assets::FrontEndAssets;
|
||||
use bichon_core::raise_error;
|
||||
@@ -123,7 +124,20 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.nest("/api-docs/spec.yaml", spec_yaml)
|
||||
.nest("/oauth2/callback", get(oauth2_callback))
|
||||
.nest("/api/status", get(get_status))
|
||||
.nest("/api/login", post(login))
|
||||
.nest("/api/login", post(login));
|
||||
|
||||
let app_logic = if SETTINGS.bichon_enable_mcp {
|
||||
app_logic.nest_no_strip(
|
||||
"/mcp",
|
||||
mcp_endpoint()
|
||||
.with(ApiGuard)
|
||||
.with(Timeout),
|
||||
)
|
||||
} else {
|
||||
app_logic
|
||||
};
|
||||
|
||||
let app_logic = app_logic
|
||||
.nest_no_strip("/api/v1", open_api_route)
|
||||
.nest_no_strip(
|
||||
"/assets",
|
||||
|
||||
@@ -138,6 +138,8 @@ export type ServerConfigurations = {
|
||||
bichon_smtp_auth_required: boolean
|
||||
bichon_smtp_tls_key_path?: string | null
|
||||
bichon_smtp_tls_cert_path?: string | null
|
||||
|
||||
bichon_enable_mcp: boolean
|
||||
}
|
||||
|
||||
export const get_dashboard_stats = async () => {
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
Activity,
|
||||
InfoIcon,
|
||||
Mail,
|
||||
Zap
|
||||
Zap,
|
||||
Bot
|
||||
} from "lucide-react"
|
||||
import { get_system_configurations } from "@/api/system/api"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
@@ -180,6 +181,17 @@ export default function ServerConfigurationsPage() {
|
||||
<SettingRow label="BICHON_SMTP_AUTH_REQUIRED" value={<BooleanBadge value={data!.bichon_smtp_auth_required} />} />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
icon={Bot}
|
||||
title={t("systemConfig.sections.integration.title")}
|
||||
description={t("systemConfig.sections.integration.desc")}
|
||||
>
|
||||
<SettingRow
|
||||
label="BICHON_ENABLE_MCP"
|
||||
value={<BooleanBadge value={data!.bichon_enable_mcp} />}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
icon={Zap}
|
||||
title={t("systemConfig.sections.performance.title")}
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "قواعد الوصول عبر الأصول",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "تكامل بروتوكول المساعد الذكي.",
|
||||
"title": "التكامل الخارجي"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "سلوك إخراج السجلات",
|
||||
"title": "التسجيل"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Adgangsregler for cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integration af AI-assistentprotokol.",
|
||||
"title": "Ekstern integration"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Log-output adfærd",
|
||||
"title": "Logning"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Cross-Origin-Zugriffsregeln",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integration des KI-Assistenten-Protokolls.",
|
||||
"title": "Externe Integration"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Verhalten der Log-Ausgabe",
|
||||
"title": "Protokollierung"
|
||||
|
||||
@@ -1318,6 +1318,10 @@
|
||||
"desc": "Cross-origin access rules",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AI assistant protocol integration.",
|
||||
"title": "External Integration"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Log output behavior",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Reglas de acceso de origen cruzado",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integración del protocolo del asistente de IA.",
|
||||
"title": "Integración externa"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Comportamiento de salida de logs",
|
||||
"title": "Registro"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Cross-origin-käyttösäännöt",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Tekoälyavustajan protokollaintegraatio.",
|
||||
"title": "Ulkoinen integraatio"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Lokien tulostuskäyttäytyminen",
|
||||
"title": "Lokitukset"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Règles d'accès cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Intégration du protocole d'assistant IA.",
|
||||
"title": "Intégration externe"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Comportement de sortie des logs",
|
||||
"title": "Journalisation"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Regole di accesso cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integrazione del protocollo dell'assistente IA.",
|
||||
"title": "Integrazione esterna"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Comportamento dell'output dei log",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "クロスオリジンアクセスのルール",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AIアシスタントプロトコルの統合。",
|
||||
"title": "外部統合"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "ログ出力の動作",
|
||||
"title": "ロギング"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "교차 출처 액세스 규칙",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AI 어시스턴트 프로토콜 연동.",
|
||||
"title": "외부 연동"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "로그 출력 동작",
|
||||
"title": "로깅"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Cross-origin toegangsregels",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integratie van AI-assistentprotocol.",
|
||||
"title": "Externe integratie"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Gedrag van log-output",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Tilgangsregler for cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integrasjon av AI-assistentprotokoll.",
|
||||
"title": "Ekstern integrasjon"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Loggutdata-oppførsel",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Zasady dostępu cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integracja protokołu asystenta AI.",
|
||||
"title": "Zewnętrzna integracja"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Zachowanie wyjściowe logów",
|
||||
"title": "Logowanie"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Regras de acesso cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integração do protocolo do assistente de IA.",
|
||||
"title": "Integração externa"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Comportamento de saída de logs",
|
||||
"title": "Logging"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Правила кросс-доменного доступа",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Интеграция протокола ИИ-ассистента.",
|
||||
"title": "Внешняя интеграция"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Поведение вывода логов",
|
||||
"title": "Логирование"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "Åtkomstregler för cross-origin",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "Integrering av AI-assistentprotokoll.",
|
||||
"title": "Extern integration"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "Loggutmatning",
|
||||
"title": "Loggning"
|
||||
|
||||
@@ -1317,6 +1317,10 @@
|
||||
"desc": "跨來源資源共用規則",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AI 助手協定集成。",
|
||||
"title": "外部集成"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "日誌輸出行為",
|
||||
"title": "日誌"
|
||||
|
||||
@@ -1318,6 +1318,10 @@
|
||||
"desc": "跨域访问规则",
|
||||
"title": "CORS"
|
||||
},
|
||||
"integration": {
|
||||
"desc": "AI 助手协议集成。",
|
||||
"title": "外部集成"
|
||||
},
|
||||
"logging": {
|
||||
"desc": "日志输出行为",
|
||||
"title": "日志"
|
||||
|
||||
Reference in New Issue
Block a user