feat: 添加MinIO文件系统支持并优化外部连接器功能

- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
2026-06-05 11:46:40 +08:00
parent 236ac19789
commit 2c5205b06e
120 changed files with 8321 additions and 1865 deletions

View File

@ -185,6 +185,13 @@ class LiteLLMProvider(LLMProvider):
kwargs["provider"] = provider_payload
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
if self._uses_mistral_reasoning_parser(original_model, resolved_model):
if enabled is not None:
extra_body = dict(kwargs.get("extra_body") or {})
extra_body["reasoning_effort"] = "high" if enabled else "none"
kwargs["extra_body"] = extra_body
return
extra_body = dict(kwargs.get("extra_body") or {})
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
chat_template_kwargs["enable_thinking"] = False
@ -192,6 +199,12 @@ class LiteLLMProvider(LLMProvider):
extra_body["thinking"] = {"type": "disabled"}
kwargs["extra_body"] = extra_body
def _uses_mistral_reasoning_parser(self, original_model: str, resolved_model: str) -> bool:
if self.provider_name != "vllm":
return False
model_names = f"{original_model} {resolved_model}".lower()
return "mistral" in model_names
async def chat(
self,
messages: list[dict[str, Any]],

View File

@ -8,6 +8,18 @@ from .models import ChannelRuntimeSpec, ValidationResult
from .sidecar_client import ConnectorSidecarClient
from .store import ChannelConnectionStore, CredentialStore
POLICY_CONFIG_KEYS = {
"allowFrom",
"groupAllowFrom",
"requireMentionInGroups",
"respondToMentionAll",
"dmMode",
"maxMessageChars",
"textBatchDelayMs",
"textBatchMaxMessages",
"textBatchMaxChars",
}
class ExternalConnectorBase:
kind = ""
@ -33,6 +45,8 @@ class ExternalConnectorBase:
owner_user_id: str | None,
options: dict[str, Any],
) -> dict[str, Any]:
runtime_config = {"sidecarBaseUrl": self.sidecar_base_url}
runtime_config.update(_policy_runtime_config(options))
connection = self.connection_store.create(
kind=self.kind,
mode="sidecar",
@ -40,7 +54,7 @@ class ExternalConnectorBase:
account_id="",
owner_user_id=owner_user_id,
auth_type="connector_session",
runtime_config={"sidecarBaseUrl": self.sidecar_base_url},
runtime_config=runtime_config,
capabilities=list(self.capabilities),
)
connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None)
@ -54,7 +68,8 @@ class ExternalConnectorBase:
}
view = dict(await self.sidecar_client.start_session(payload))
connection.pairing_session_id = str(view.get("sessionId") or "")
self.connection_store.update(connection)
connection = self.connection_store.update(connection)
connection = self._apply_session_view(connection, view)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
@ -62,6 +77,12 @@ class ExternalConnectorBase:
async def poll_session(self, session_id: str) -> dict[str, Any]:
view = dict(await self.sidecar_client.get_session(session_id))
connection = self._connection_for_session(session_id)
connection = self._apply_session_view(connection, view)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
def _apply_session_view(self, connection: Any, view: dict[str, Any]) -> Any:
status = str(view.get("status") or "")
if status == "connected":
connection.account_id = str(view.get("accountId") or connection.account_id)
@ -78,9 +99,7 @@ class ExternalConnectorBase:
status="error",
last_error=str(view.get("error") or status),
)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
return self.connection_store.get(connection.connection_id)
async def validate(self, connection_id: str) -> ValidationResult:
connection = self.connection_store.get(connection_id)
@ -106,6 +125,7 @@ class ExternalConnectorBase:
config={
"platformKind": self.kind,
"connectionId": connection.connection_id,
**dict(connection.runtime_config),
"sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url,
},
secrets_ref=None,
@ -129,3 +149,52 @@ class WeixinConnector(ExternalConnectorBase):
class FeishuConnector(ExternalConnectorBase):
kind = "feishu"
capabilities = ["receive_text", "send_text", "receive_media", "groups"]
def _policy_runtime_config(options: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key in POLICY_CONFIG_KEYS:
if key not in options:
continue
value = options[key]
if key in {"allowFrom", "groupAllowFrom"}:
items = _string_list(value)
if items:
result[key] = items
continue
if key in {"maxMessageChars", "textBatchDelayMs", "textBatchMaxMessages", "textBatchMaxChars"}:
number = _positive_int(value)
if number is not None:
result[key] = number
continue
if key in {"requireMentionInGroups", "respondToMentionAll"}:
result[key] = _bool(value)
continue
text = str(value or "").strip()
if text:
result[key] = text
return result
def _string_list(value: Any) -> list[str]:
if isinstance(value, str):
raw_items = value.replace("\n", ",").split(",")
elif isinstance(value, list):
raw_items = value
else:
raw_items = []
return [str(item).strip() for item in raw_items if str(item).strip()]
def _positive_int(value: Any) -> int | None:
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _bool(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}

View File

@ -431,6 +431,37 @@ def _connection_response_view(connection: Any) -> dict[str, Any]:
return view
def _connector_session_response_view(view: dict[str, Any]) -> dict[str, Any]:
result = dict(view)
metadata = result.get("metadata")
if isinstance(metadata, dict):
result["metadata"] = {
str(key): value
for key, value in metadata.items()
if not _is_sensitive_metadata_key(str(key))
}
return result
def _is_sensitive_metadata_key(key: str) -> bool:
lowered = key.lower()
return any(token in lowered for token in ("secret", "token", "password", "authorization", "credential"))
async def _activate_connected_channel(
request: Request,
registry: ChannelConnectorRegistry,
connection: Any,
) -> Any:
if connection.status != "connected":
return connection
runtime = get_channel_runtime(request)
config = (await registry.materialize_channel_configs()).get(connection.channel_id)
if config is not None:
await runtime.add_channel(connection.channel_id, config)
return registry.connection_store.get(connection.connection_id)
def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(config, dict):
return {}
@ -441,6 +472,36 @@ def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any
}
def _connector_bridge_guard(connection: Any, payload: WebConnectorBridgeEventRequest) -> None:
if connection.status == "revoked":
raise HTTPException(status_code=404, detail="Channel connection not found")
if connection.status not in {"connected", "running"}:
raise HTTPException(status_code=409, detail="Channel connection is not connected")
mismatches: list[str] = []
if payload.channel_id != connection.channel_id:
mismatches.append("channelId")
if payload.kind != connection.kind:
mismatches.append("kind")
if payload.account_id != connection.account_id:
mismatches.append("accountId")
if mismatches:
raise HTTPException(status_code=403, detail=f"Bridge event does not match connection: {', '.join(mismatches)}")
content = payload.content.strip()
if not content:
raise HTTPException(status_code=400, detail="Bridge event content is required")
max_chars = _positive_int(connection.runtime_config.get("maxMessageChars"), default=20000)
if len(content) > max_chars:
raise HTTPException(status_code=413, detail=f"Bridge event content exceeds maxMessageChars ({max_chars})")
def _positive_int(value: Any, *, default: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return default
return number if number > 0 else default
def _camel_to_snake_text(value: str) -> str:
result: list[str] = []
for char in value.strip():
@ -721,8 +782,10 @@ def create_app(
connection_id = _clean_text(view.get("connectionId"))
connection_view = None
if connection_id:
connection_view = _connection_response_view(registry.connection_store.get(connection_id))
return WebConnectorSessionResponse(session=view, connection=connection_view)
connection = registry.connection_store.get(connection_id)
connection = await _activate_connected_channel(request, registry, connection)
connection_view = _connection_response_view(connection)
return WebConnectorSessionResponse(session=_connector_session_response_view(view), connection=connection_view)
@app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
@ -739,11 +802,11 @@ def create_app(
raise HTTPException(status_code=400, detail="Connector does not support sessions")
view = await poll_session(session_id)
connection = registry.connection_store.get(connection.connection_id)
if connection.status == "connected":
runtime = get_channel_runtime(request)
config = (await registry.materialize_channel_configs())[connection.channel_id]
await runtime.add_channel(connection.channel_id, config)
return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))
connection = await _activate_connected_channel(request, registry, connection)
return WebConnectorSessionResponse(
session=_connector_session_response_view(view),
connection=_connection_response_view(connection),
)
@app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
async def accept_connector_bridge_event(
@ -760,8 +823,7 @@ def create_app(
connection = registry.connection_store.get(payload.connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
if connection.status == "revoked":
raise HTTPException(status_code=404, detail="Channel connection not found")
_connector_bridge_guard(connection, payload)
store = _message_dedupe_store(_channel_connection_workspace(request))
begin = store.begin(

View File

@ -604,6 +604,8 @@ class AgentService:
if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"):
active_task.metadata["short_title"] = decision.short_title
task_service.store.upsert_task(active_task)
if active_task is not None and (decision.action == "simple_chat" or decision.starts_new_task):
await self._accept_active_task_for_new_topic(active_task)
if active_task is not None and decision.closes_task:
task_service.close_task(active_task.task_id, reason=decision.reason)
return await runner(message, **kwargs)
@ -636,6 +638,20 @@ class AgentService:
)
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
async def _accept_active_task_for_new_topic(self, task: TaskRecord) -> None:
"""Accept a completed active Task before routing an unrelated new topic."""
if task.status != "awaiting_acceptance":
return
run_id = next((item for item in reversed(task.run_ids) if item), None)
if not run_id:
return
await self.submit_acceptance(
session_id=task.session_id,
run_id=run_id,
acceptance_type="accept",
)
def _record_revision_acceptance_for_task(
self,
loaded: Any,

View File

@ -32,16 +32,23 @@ When there is an active task, do not force every new user message into that task
- Choose `revise_task` when the user asks to change, correct, refine, expand, reformat, or redo the latest active task result.
- Choose `continue_task` for neutral follow-up questions or additional next steps that still belong to the active task.
- Choose `new_task` when the user asks for clearly unrelated work.
- Choose `simple_chat` for unrelated lightweight conversation. This starts a new topic and the previous task will be accepted automatically.
- Choose `new_task` when the user asks for clearly unrelated work that needs Task capabilities. This starts a new topic and the previous task will be accepted automatically.
- Choose `close_task` when the user says the task is satisfactory or finished, such as "可以了", "就这样", or "that's good".
- Choose `abandon_task` when the user says to stop, cancel, or no longer do the active task.
Do not classify unrelated lightweight conversation as `revise_task` merely because
the active task is awaiting acceptance. A revision must ask to change or correct
the active task result.
Examples with an active weather task:
- "再详细一点" -> `revise_task`
- "加上明后天穿衣建议" -> `revise_task`
- "顺便查一下深圳" -> `continue_task`
- "帮我写一个采购合同" -> `new_task`
- "吃饭没" -> `simple_chat`
- "我在冰岛" -> `simple_chat`
- "可以了" -> `close_task`
- "不用了" -> `abandon_task`

View File

@ -161,6 +161,9 @@ class MainAgentRouter:
"Critical policy:\n"
"- If there is an active Task, choose continue_task or revise_task unless the user's topic is completely unrelated "
"to that Task or the user explicitly closes/abandons it.\n"
"- With an active Task, choose simple_chat for unrelated lightweight conversation and new_task for unrelated work "
"that needs Task capabilities. Either decision starts a new topic.\n"
"- An unrelated lightweight conversation must not be classified as revise_task merely because the active Task is awaiting acceptance.\n"
"- Choose revise_task when the active Task is awaiting feedback or needs revision and the user asks for changes "
"such as '改一下', '加上', '删除', '换成', '再详细点', '格式改成', '不要', or equivalent wording.\n"
"- Choose continue_task for neutral follow-up questions or additional next steps that do not imply dissatisfaction with the previous result.\n"