mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
同步:同步主线
This commit is contained in:
@@ -12,14 +12,74 @@ class ContextTruncator:
|
||||
and len(message.tool_calls) > 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _split_system_rest(
|
||||
messages: list[Message],
|
||||
) -> tuple[list[Message], list[Message]]:
|
||||
"""Split messages into system messages and the rest.
|
||||
|
||||
Returns:
|
||||
tuple: (system_messages, non_system_messages)
|
||||
"""
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
return messages[:first_non_system], messages[first_non_system:]
|
||||
|
||||
@staticmethod
|
||||
def _ensure_user_message(
|
||||
system_messages: list[Message],
|
||||
truncated: list[Message],
|
||||
original_messages: list[Message],
|
||||
) -> list[Message]:
|
||||
"""Ensure the result always contains a `user` message immediately after
|
||||
system messages, as required by some LLM APIs.
|
||||
|
||||
Optimization strategy:
|
||||
- If `truncated` already begins with a `user` message, return it as-is.
|
||||
- If a `user` message exists later in `truncated`, move that message to
|
||||
be the first non-system message while preserving the relative order of
|
||||
the remaining truncated messages (without mutating the original list).
|
||||
- Otherwise, fall back to the first `user` message from
|
||||
`original_messages`.
|
||||
This reduces unnecessary duplication and ensures the required ordering.
|
||||
"""
|
||||
if truncated and truncated[0].role == "user":
|
||||
return system_messages + truncated
|
||||
|
||||
# If a user message exists inside the truncated list, promote it to the front.
|
||||
index_in_truncated = next(
|
||||
(i for i, m in enumerate(truncated) if m.role == "user"), None
|
||||
)
|
||||
if index_in_truncated is not None:
|
||||
# Build a new truncated list that places the found user message first,
|
||||
# preserving the order of the other messages and avoiding in-place mutation.
|
||||
user_msg = truncated[index_in_truncated]
|
||||
new_truncated = [
|
||||
user_msg,
|
||||
*truncated[:index_in_truncated],
|
||||
*truncated[index_in_truncated + 1 :],
|
||||
]
|
||||
return system_messages + new_truncated
|
||||
|
||||
# Fallback: find the first user message in the original messages.
|
||||
first_user = next((m for m in original_messages if m.role == "user"), None)
|
||||
if first_user is None:
|
||||
# No user messages at all; return system messages + whatever was truncated.
|
||||
return system_messages + truncated
|
||||
|
||||
return [*system_messages, first_user, *truncated]
|
||||
|
||||
def fix_messages(self, messages: list[Message]) -> list[Message]:
|
||||
"""修复消息列表,确保 tool call 和 tool response 的配对关系有效。
|
||||
"""Fix the message list to ensure the validity of tool call and tool response pairing.
|
||||
|
||||
此方法确保:
|
||||
1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息
|
||||
2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应
|
||||
This method ensures that:
|
||||
1. Each `tool` message is preceded by an `assistant` message containing `tool_calls`.
|
||||
2. Each `assistant` message containing `tool_calls` is followed by corresponding `
|
||||
|
||||
这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。
|
||||
This is a requirement of the OpenAI Chat Completions API specification (Gemini enforces this strictly).
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
@@ -38,24 +98,25 @@ class ContextTruncator:
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "tool":
|
||||
# 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应
|
||||
# Only record tool responses when there is a pending assistant(tool_calls)
|
||||
if pending_assistant is not None:
|
||||
pending_tools.append(msg)
|
||||
# else: 孤立的 tool 消息,直接忽略
|
||||
# Isolated tool messages without a preceding assistant(tool_calls) are ignored
|
||||
continue
|
||||
|
||||
if self._has_tool_calls(msg):
|
||||
# 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链
|
||||
# When encountering a new assistant(tool_calls), first process the old pending chain
|
||||
flush_pending_if_valid()
|
||||
pending_assistant = msg
|
||||
continue
|
||||
|
||||
# 非 tool,且不含 tool_calls 的消息
|
||||
# 先结束任何 pending 链,再正常追加
|
||||
# Non-tool messages that do not contain tool_calls will break the pending chain.
|
||||
# Flush any pending chain first, then append the current message normally.
|
||||
flush_pending_if_valid()
|
||||
fixed_messages.append(msg)
|
||||
|
||||
# 结束时处理最后一个 pending 链
|
||||
# Flush the last pending chain at the end,
|
||||
# ensuring that any remaining valid assistant(tool_calls) and its tools are included in the final list.
|
||||
flush_pending_if_valid()
|
||||
|
||||
return fixed_messages
|
||||
@@ -66,29 +127,23 @@ class ContextTruncator:
|
||||
keep_most_recent_turns: int,
|
||||
drop_turns: int = 1,
|
||||
) -> list[Message]:
|
||||
"""截断上下文列表,确保不超过最大长度。
|
||||
一个 turn 包含一个 user 消息和一个 assistant 消息。
|
||||
这个方法会保证截断后的上下文列表符合 OpenAI 的上下文格式。
|
||||
"""
|
||||
Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns.
|
||||
A turn consists of a user message and an assistant message.
|
||||
This method ensures that the truncated context list conforms to OpenAI's context format.
|
||||
|
||||
Args:
|
||||
messages: 上下文列表
|
||||
keep_most_recent_turns: 保留最近的对话轮数
|
||||
drop_turns: 一次性丢弃的对话轮数
|
||||
messages: The original list of messages in the context.
|
||||
keep_most_recent_turns: The number of most recent turns to keep. If set to -1, it means keeping all turns (no truncation).
|
||||
drop_turns: The number of turns to drop from the beginning.
|
||||
|
||||
Returns:
|
||||
截断后的上下文列表
|
||||
The truncated list of messages.
|
||||
"""
|
||||
if keep_most_recent_turns == -1:
|
||||
return messages
|
||||
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
system_messages, non_system_messages = self._split_system_rest(messages)
|
||||
|
||||
if len(non_system_messages) // 2 <= keep_most_recent_turns:
|
||||
return messages
|
||||
@@ -99,7 +154,7 @@ class ContextTruncator:
|
||||
else:
|
||||
truncated_contexts = non_system_messages[-num_to_keep * 2 :]
|
||||
|
||||
# 找到第一个 role 为 user 的索引,确保上下文格式正确
|
||||
# Find the first user message
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_contexts) if item.role == "user"),
|
||||
None,
|
||||
@@ -107,8 +162,9 @@ class ContextTruncator:
|
||||
if index is not None and index > 0:
|
||||
truncated_contexts = truncated_contexts[index:]
|
||||
|
||||
result = system_messages + truncated_contexts
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages, truncated_contexts, messages
|
||||
)
|
||||
return self.fix_messages(result)
|
||||
|
||||
def truncate_by_dropping_oldest_turns(
|
||||
@@ -116,53 +172,39 @@ class ContextTruncator:
|
||||
messages: list[Message],
|
||||
drop_turns: int = 1,
|
||||
) -> list[Message]:
|
||||
"""丢弃最旧的 N 个对话轮次。"""
|
||||
"""Drop the oldest N turns, regardless of the number of turns to keep."""
|
||||
if drop_turns <= 0:
|
||||
return messages
|
||||
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
system_messages, non_system_messages = self._split_system_rest(messages)
|
||||
|
||||
if len(non_system_messages) // 2 <= drop_turns:
|
||||
truncated_non_system = []
|
||||
else:
|
||||
truncated_non_system = non_system_messages[drop_turns * 2 :]
|
||||
|
||||
# Find the first user message
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_non_system) if item.role == "user"),
|
||||
None,
|
||||
)
|
||||
if index is not None:
|
||||
truncated_non_system = truncated_non_system[index:]
|
||||
elif truncated_non_system:
|
||||
truncated_non_system = []
|
||||
|
||||
result = system_messages + truncated_non_system
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages, truncated_non_system, messages
|
||||
)
|
||||
return self.fix_messages(result)
|
||||
|
||||
def truncate_by_halving(
|
||||
self,
|
||||
messages: list[Message],
|
||||
) -> list[Message]:
|
||||
"""对半砍策略,删除 50% 的消息"""
|
||||
"""Halve the number of messages, keeping the most recent ones."""
|
||||
if len(messages) <= 2:
|
||||
return messages
|
||||
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
system_messages, non_system_messages = self._split_system_rest(messages)
|
||||
|
||||
messages_to_delete = len(non_system_messages) // 2
|
||||
if messages_to_delete == 0:
|
||||
@@ -170,6 +212,7 @@ class ContextTruncator:
|
||||
|
||||
truncated_non_system = non_system_messages[messages_to_delete:]
|
||||
|
||||
# Find the first user message
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_non_system) if item.role == "user"),
|
||||
None,
|
||||
@@ -177,6 +220,7 @@ class ContextTruncator:
|
||||
if index is not None:
|
||||
truncated_non_system = truncated_non_system[index:]
|
||||
|
||||
result = system_messages + truncated_non_system
|
||||
|
||||
result = self._ensure_user_message(
|
||||
system_messages, truncated_non_system, messages
|
||||
)
|
||||
return self.fix_messages(result)
|
||||
|
||||
@@ -1117,6 +1117,20 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"Kimi Coding Plan": {
|
||||
"id": "kimi-code",
|
||||
"provider": "kimi-code",
|
||||
"type": "kimi_code_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.kimi.com/coding/",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {"User-Agent": "claude-code/0.1.0"},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"Moonshot": {
|
||||
|
||||
@@ -172,6 +172,9 @@ def try_capture_follow_up(event: AstrMessageEvent) -> FollowUpCapture | None:
|
||||
if not active_sender_id or active_sender_id != sender_id:
|
||||
return None
|
||||
|
||||
if runner_event.get_extra("agent_stop_requested"):
|
||||
return None
|
||||
|
||||
ticket = runner.follow_up(message_text=_event_follow_up_text(event))
|
||||
if not ticket:
|
||||
return None
|
||||
|
||||
@@ -380,6 +380,10 @@ class ProviderManager:
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
)
|
||||
case "kimi_code_chat_completion":
|
||||
from .sources.kimi_code_source import (
|
||||
ProviderKimiCode as ProviderKimiCode,
|
||||
)
|
||||
case "googlegenai_chat_completion":
|
||||
from .sources.gemini_source import (
|
||||
ProviderGoogleGenAI as ProviderGoogleGenAI,
|
||||
|
||||
@@ -17,7 +17,6 @@ from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.utils.network_utils import (
|
||||
create_proxy_client,
|
||||
is_connection_error,
|
||||
log_connection_failure,
|
||||
)
|
||||
@@ -30,6 +29,30 @@ from ..register import register_provider_adapter
|
||||
"Anthropic Claude API 提供商适配器",
|
||||
)
|
||||
class ProviderAnthropic(Provider):
|
||||
@staticmethod
|
||||
def _normalize_custom_headers(provider_config: dict) -> dict[str, str] | None:
|
||||
custom_headers = provider_config.get("custom_headers", {})
|
||||
if not isinstance(custom_headers, dict) or not custom_headers:
|
||||
return None
|
||||
normalized_headers: dict[str, str] = {}
|
||||
for key, value in custom_headers.items():
|
||||
normalized_headers[str(key)] = str(value)
|
||||
return normalized_headers or None
|
||||
|
||||
@classmethod
|
||||
def _resolve_custom_headers(
|
||||
cls,
|
||||
provider_config: dict,
|
||||
*,
|
||||
required_headers: dict[str, str] | None = None,
|
||||
) -> dict[str, str] | None:
|
||||
merged_headers = cls._normalize_custom_headers(provider_config) or {}
|
||||
if required_headers:
|
||||
for header_name, header_value in required_headers.items():
|
||||
if not merged_headers.get(header_name, "").strip():
|
||||
merged_headers[header_name] = header_value
|
||||
return merged_headers or None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_config,
|
||||
@@ -47,6 +70,7 @@ class ProviderAnthropic(Provider):
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
self.custom_headers = self._resolve_custom_headers(provider_config)
|
||||
|
||||
if use_api_key:
|
||||
self._init_api_key(provider_config)
|
||||
@@ -67,7 +91,12 @@ class ProviderAnthropic(Provider):
|
||||
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
|
||||
"""创建带代理的 HTTP 客户端"""
|
||||
proxy = provider_config.get("proxy", "")
|
||||
return create_proxy_client("Anthropic", proxy)
|
||||
if proxy:
|
||||
logger.info(f"[Anthropic] 使用代理: {proxy}")
|
||||
return httpx.AsyncClient(proxy=proxy, headers=self.custom_headers)
|
||||
if self.custom_headers:
|
||||
return httpx.AsyncClient(headers=self.custom_headers)
|
||||
return None
|
||||
|
||||
def _apply_thinking_config(self, payloads: dict) -> None:
|
||||
thinking_type = self.thinking_config.get("type", "")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from ..register import register_provider_adapter
|
||||
from .anthropic_source import ProviderAnthropic
|
||||
|
||||
KIMI_CODE_API_BASE = "https://api.kimi.com/coding"
|
||||
KIMI_CODE_DEFAULT_MODEL = "kimi-for-coding"
|
||||
KIMI_CODE_USER_AGENT = "claude-code/0.1.0"
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"kimi_code_chat_completion",
|
||||
"Kimi Code Provider Adapter",
|
||||
)
|
||||
class ProviderKimiCode(ProviderAnthropic):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
merged_provider_config = dict(provider_config)
|
||||
merged_provider_config.setdefault("api_base", KIMI_CODE_API_BASE)
|
||||
merged_provider_config.setdefault("model", KIMI_CODE_DEFAULT_MODEL)
|
||||
merged_provider_config["custom_headers"] = self._resolve_custom_headers(
|
||||
merged_provider_config,
|
||||
required_headers={"User-Agent": KIMI_CODE_USER_AGENT},
|
||||
)
|
||||
|
||||
super().__init__(merged_provider_config, provider_settings)
|
||||
@@ -19,17 +19,15 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
self.provider_config = provider_config
|
||||
self.provider_settings = provider_settings
|
||||
proxy = provider_config.get("proxy", "")
|
||||
provider_id = provider_config.get("id", "unknown_id")
|
||||
http_client = None
|
||||
if proxy:
|
||||
logger.info(f"[OpenAI Embedding] 使用代理: {proxy}")
|
||||
logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}")
|
||||
http_client = httpx.AsyncClient(proxy=proxy)
|
||||
api_base = provider_config.get("embedding_api_base", "").strip()
|
||||
if not api_base:
|
||||
api_base = "https://api.openai.com/v1"
|
||||
else:
|
||||
api_base = api_base.removesuffix("/")
|
||||
if not api_base.endswith("/v1"):
|
||||
api_base = f"{api_base}/v1"
|
||||
api_base = provider_config.get(
|
||||
"embedding_api_base", "https://api.openai.com/v1"
|
||||
).strip()
|
||||
logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}")
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=provider_config.get("embedding_api_key"),
|
||||
base_url=api_base,
|
||||
|
||||
@@ -4,7 +4,10 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
astrbot:
|
||||
image: soulter/astrbot:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: astrbot:kimi-code
|
||||
container_name: astrbot
|
||||
restart: always
|
||||
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497
|
||||
|
||||
@@ -1,24 +1,182 @@
|
||||
<template>
|
||||
<div class="folder-item-selector">
|
||||
<!-- 触发按钮区域 -->
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span
|
||||
v-if="!modelValue"
|
||||
style="color: rgb(var(--v-theme-primaryText));"
|
||||
>
|
||||
{{ labels.notSelected || '未选择' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="openDialog"
|
||||
>
|
||||
{{ labels.buttonText || '选择...' }}
|
||||
</v-btn>
|
||||
<div class="folder-item-selector">
|
||||
<!-- 触发按钮区域 -->
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
|
||||
{{ labels.notSelected || '未选择' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
{{ labels.buttonText || '选择...' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 选择对话框 -->
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
:max-width="isCompactLayout ? '96vw' : '1000px'"
|
||||
:min-width="isCompactLayout ? undefined : '800px'"
|
||||
>
|
||||
<v-card class="selector-dialog-card">
|
||||
<v-card-title class="dialog-title d-flex align-center" :class="isCompactLayout ? 'py-3 px-4' : 'py-4 px-5'">
|
||||
<v-icon class="mr-3" color="primary">mdi-account-circle</v-icon>
|
||||
<span>{{ labels.dialogTitle || '选择项目' }}</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-0 selector-content">
|
||||
<div class="selector-layout">
|
||||
<!-- 左侧文件夹树 -->
|
||||
<div v-if="!isCompactLayout" class="folder-sidebar">
|
||||
<div class="sidebar-header pa-3 pb-2">
|
||||
<span class="text-caption text-medium-emphasis font-weight-medium">
|
||||
<v-icon size="small" class="mr-1">mdi-folder-multiple</v-icon>
|
||||
文件夹
|
||||
</span>
|
||||
</div>
|
||||
<v-list density="compact" nav class="tree-list pa-2" bg-color="transparent">
|
||||
<!-- 根目录 -->
|
||||
<v-list-item :active="currentFolderId === null" @click="navigateToFolder(null)"
|
||||
rounded="lg" class="mb-1 root-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="20" :color="currentFolderId === null ? 'primary' : ''">mdi-home</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">{{ labels.rootFolder || '根目录' }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 文件夹树 -->
|
||||
<template v-if="!treeLoading">
|
||||
<BaseMoveTargetNode v-for="folder in folderTree" :key="folder.folder_id"
|
||||
:folder="folder" :depth="0" :selected-folder-id="currentFolderId"
|
||||
:disabled-folder-ids="[]" @select="navigateToFolder" />
|
||||
</template>
|
||||
|
||||
<div v-if="treeLoading" class="text-center pa-4">
|
||||
<v-progress-circular indeterminate size="20" color="primary" />
|
||||
</div>
|
||||
</v-list>
|
||||
</div>
|
||||
|
||||
<!-- 右侧项目列表 -->
|
||||
<div class="items-panel">
|
||||
<div v-if="isCompactLayout" class="mobile-folder-bar px-4 py-2">
|
||||
<v-btn icon="mdi-arrow-left" size="small" variant="text"
|
||||
:disabled="currentFolderId === null" @click="navigateToParentFolder" />
|
||||
<v-btn size="small" variant="tonal" color="primary" prepend-icon="mdi-home"
|
||||
@click="navigateToFolder(null)">
|
||||
{{ labels.rootFolder || '根目录' }}
|
||||
</v-btn>
|
||||
<span class="text-caption text-medium-emphasis text-truncate mobile-folder-label">
|
||||
{{ currentFolderLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<v-divider v-if="isCompactLayout" />
|
||||
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="breadcrumb-bar px-4 py-3">
|
||||
<v-breadcrumbs :items="breadcrumbItems" density="compact" class="pa-0">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item :disabled="(item as any).disabled"
|
||||
@click="!(item as any).disabled && navigateToFolder((item as any).folderId)"
|
||||
:class="{ 'breadcrumb-link': !(item as any).disabled }">
|
||||
<v-icon v-if="(item as any).isRoot" size="small"
|
||||
class="mr-1">mdi-home</v-icon>
|
||||
{{ item.title }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
<template v-slot:divider>
|
||||
<v-icon size="small" color="grey">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div class="items-list">
|
||||
<v-progress-linear v-if="itemsLoading" indeterminate
|
||||
color="primary" height="2"></v-progress-linear>
|
||||
|
||||
<!-- 子文件夹 -->
|
||||
<v-list v-if="!itemsLoading" lines="two" class="pa-3 items-content">
|
||||
<template v-if="currentSubFolders.length > 0">
|
||||
<div class="section-label text-caption text-medium-emphasis mb-2 px-2">子文件夹</div>
|
||||
<v-list-item v-for="folder in currentSubFolders" :key="'folder-' + folder.folder_id"
|
||||
@click="navigateToFolder(folder.folder_id)" rounded="lg" class="mb-1 folder-item">
|
||||
<template v-slot:prepend>
|
||||
<v-avatar size="36" color="amber-lighten-4" class="mr-3">
|
||||
<v-icon color="amber-darken-2" size="20">mdi-folder</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-medium">{{ folder.name }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-icon size="20" color="grey">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<template v-if="currentItems.length > 0">
|
||||
<div class="section-label text-caption text-medium-emphasis mb-2 px-2" :class="{ 'mt-4': currentSubFolders.length > 0 }">可选项目</div>
|
||||
<v-list-item v-for="item in currentItems" :key="'item-' + getItemId(item)"
|
||||
:value="getItemId(item)" @click="selectItem(item)"
|
||||
:active="selectedItemId === getItemId(item)" rounded="lg" class="mb-1 persona-item"
|
||||
:class="{ 'selected-item': selectedItemId === getItemId(item) }">
|
||||
<template v-slot:prepend>
|
||||
<v-avatar size="36" :color="selectedItemId === getItemId(item) ? 'primary-lighten-4' : 'grey-lighten-3'" class="mr-3">
|
||||
<v-icon :color="selectedItemId === getItemId(item) ? 'primary' : 'grey-darken-1'" size="20">mdi-account</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-medium">{{ getItemName(item) }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="getItemDescription(item)" class="text-truncate">
|
||||
{{ truncateText(getItemDescription(item), 80) }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn v-if="showEditButton && !isDefaultItem(item)"
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="handleEditItem(item)"
|
||||
:title="labels.editButton || 'Edit'"
|
||||
/>
|
||||
<v-icon v-if="selectedItemId === getItemId(item)"
|
||||
color="primary" size="22">mdi-check-circle</v-icon>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="currentSubFolders.length === 0 && currentItems.length === 0"
|
||||
class="empty-state text-center py-12">
|
||||
<v-icon size="64" color="grey-lighten-2">mdi-folder-open-outline</v-icon>
|
||||
<p class="text-grey mt-4 text-body-2">{{ labels.emptyFolder || labels.noItems || '此文件夹为空' }}</p>
|
||||
</div>
|
||||
</v-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn v-if="showCreateButton" variant="text" color="primary" prepend-icon="mdi-plus"
|
||||
@click="$emit('create')">
|
||||
{{ labels.createButton || '新建' }}
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="cancelSelection">{{ labels.cancelButton || '取消' }}</v-btn>
|
||||
<v-btn color="primary" @click="confirmSelection" :disabled="!selectedItemId">
|
||||
{{ labels.confirmButton || '确认' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
|
||||
<!-- 选择对话框 -->
|
||||
@@ -398,6 +556,18 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isCompactLayout(): boolean {
|
||||
return this.$vuetify.display.smAndDown;
|
||||
},
|
||||
|
||||
currentFolderLabel(): string {
|
||||
if (this.currentFolderId === null) {
|
||||
return this.labels.rootFolder || '根目录';
|
||||
}
|
||||
const currentFolder = this.breadcrumbPath[this.breadcrumbPath.length - 1];
|
||||
return currentFolder?.name || this.labels.rootFolder || '根目录';
|
||||
},
|
||||
|
||||
displayValue(): string {
|
||||
if (this.displayValueFormatter) {
|
||||
return this.displayValueFormatter(this.modelValue);
|
||||
@@ -485,6 +655,20 @@ export default defineComponent({
|
||||
this.$emit('navigate', folderId);
|
||||
},
|
||||
|
||||
navigateToParentFolder() {
|
||||
if (this.currentFolderId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.breadcrumbPath.length <= 1) {
|
||||
this.navigateToFolder(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = this.breadcrumbPath[this.breadcrumbPath.length - 2];
|
||||
this.navigateToFolder(parent?.folder_id ?? null);
|
||||
},
|
||||
|
||||
findFolderInTree(folderId: string): FolderTreeNode | null {
|
||||
const findNode = (nodes: FolderTreeNode[]): FolderTreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
@@ -567,6 +751,13 @@ export default defineComponent({
|
||||
.selector-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selector-content {
|
||||
height: 600px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.folder-sidebar {
|
||||
@@ -603,6 +794,18 @@ export default defineComponent({
|
||||
|
||||
.items-content {
|
||||
background-color: transparent;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-folder-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-folder-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
@@ -670,22 +873,28 @@ export default defineComponent({
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 960px) {
|
||||
.selector-layout {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
max-height: 500px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.folder-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
max-height: 150px;
|
||||
.selector-content {
|
||||
max-height: 76vh;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
max-height: 300px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-bar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.breadcrumb-bar :deep(.v-breadcrumbs) {
|
||||
flex-wrap: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"description": {
|
||||
"openai": "Supports all OpenAI API compatible providers.",
|
||||
"kimi_code": "Dedicated Kimi CodingPlan / Kimi Code integration using a custom Anthropic API compatibility layer.",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"description": {
|
||||
"openai": "Поддерживаются все провайдеры, совместимые с OpenAI API.",
|
||||
"kimi_code": "Специальная интеграция Kimi CodingPlan / Kimi Code с отдельной совместимой адаптацией Anthropic API.",
|
||||
"vllm_rerank": "Также поддерживает Jina AI, Cohere, PPIO и другие.",
|
||||
"default": "Преобразование речи в текст"
|
||||
}
|
||||
@@ -148,4 +149,4 @@
|
||||
"modelId": "ID модели"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"description": {
|
||||
"openai": "也支持所有兼容 OpenAI API 的模型提供商。",
|
||||
"kimi_code": "Kimi 的 CodingPlan / Kimi Code 专用接入,使用特殊的 Anthropic API 兼容适配。",
|
||||
"vllm_rerank": "也支持 Jina AI, Cohere, PPIO 等提供商。",
|
||||
"default": ""
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export function getProviderIcon(type) {
|
||||
'nvidia': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/nvidia-color.svg',
|
||||
'siliconflow': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/siliconcloud.svg',
|
||||
'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
|
||||
'kimi': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
|
||||
'kimi-code': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
|
||||
'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg',
|
||||
'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg',
|
||||
"coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg",
|
||||
@@ -50,9 +52,11 @@ export function getProviderIcon(type) {
|
||||
* @returns {string} 提供商描述
|
||||
*/
|
||||
export function getProviderDescription(template, name, tm) {
|
||||
if (name == 'OpenAI') {
|
||||
if (name === 'OpenAI') {
|
||||
return tm('providers.description.openai', { type: template.type });
|
||||
} else if (name == 'vLLM Rerank') {
|
||||
} else if (template.provider === 'kimi-code') {
|
||||
return tm('providers.description.kimi_code');
|
||||
} else if (name === 'vLLM Rerank') {
|
||||
return tm('providers.description.vllm_rerank', { type: template.type });
|
||||
}
|
||||
return tm('providers.description.default', { type: template.type });
|
||||
|
||||
@@ -104,8 +104,9 @@ class TestContextTruncator:
|
||||
messages, keep_most_recent_turns=0, drop_turns=1
|
||||
)
|
||||
|
||||
# Should result in empty or minimal list
|
||||
assert len(result) == 0
|
||||
# 截断后至少保留一条 user 消息 (#6196)
|
||||
assert len(result) >= 1
|
||||
assert result[0].role == "user"
|
||||
|
||||
def test_truncate_by_turns_below_threshold(self):
|
||||
"""Test truncate_by_turns when messages are below threshold."""
|
||||
@@ -201,8 +202,9 @@ class TestContextTruncator:
|
||||
messages = self.create_messages(4)
|
||||
result = truncator.truncate_by_dropping_oldest_turns(messages, drop_turns=2)
|
||||
|
||||
# Should drop all turns
|
||||
assert len(result) == 0
|
||||
# 即使 drop 掉所有 turn,也会把 user 消息补回来 (#6196)
|
||||
assert len(result) >= 1
|
||||
assert result[0].role == "user"
|
||||
|
||||
def test_truncate_by_dropping_oldest_turns_drop_more_than_available(self):
|
||||
"""Test truncate_by_dropping_oldest_turns with drop_turns > available turns."""
|
||||
@@ -211,8 +213,9 @@ class TestContextTruncator:
|
||||
messages = self.create_messages(4)
|
||||
result = truncator.truncate_by_dropping_oldest_turns(messages, drop_turns=5)
|
||||
|
||||
# Should result in empty list
|
||||
assert len(result) == 0
|
||||
# 同理,user 消息会被保留 (#6196)
|
||||
assert len(result) >= 1
|
||||
assert result[0].role == "user"
|
||||
|
||||
def test_truncate_by_dropping_oldest_turns_ensures_user_first(self):
|
||||
"""Test that result starts with user message after dropping."""
|
||||
@@ -372,3 +375,60 @@ class TestContextTruncator:
|
||||
assert len(result) >= 0 # May keep system messages or clear all
|
||||
if len(result) > 0:
|
||||
assert all(msg.role == "system" for msg in result)
|
||||
|
||||
# ==================== #6196: 长 tool chain 只有一条 user 消息 ====================
|
||||
|
||||
def _build_tool_chain(self, tool_rounds: int = 20) -> list[Message]:
|
||||
"""构造 system -> user -> (assistant -> tool) * N 的长链,只有一条 user。"""
|
||||
msgs = [
|
||||
self.create_message("system", "You are a helpful assistant."),
|
||||
self.create_message("user", "帮我查一下天气"),
|
||||
]
|
||||
for i in range(tool_rounds):
|
||||
msgs.append(self.create_message("assistant", f"调用工具 {i}"))
|
||||
msgs.append(self.create_message("tool", f"工具结果 {i}"))
|
||||
return msgs
|
||||
|
||||
def test_drop_oldest_preserves_sole_user(self):
|
||||
"""#6196: drop 1 turn 不应丢掉唯一的 user 消息。"""
|
||||
truncator = ContextTruncator()
|
||||
msgs = self._build_tool_chain(20) # 1 system + 1 user + 40 asst/tool = 42
|
||||
result = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=1)
|
||||
roles = [m.role for m in result]
|
||||
assert "user" in roles, "唯一的 user 消息被丢掉了"
|
||||
assert roles[0] == "system"
|
||||
|
||||
def test_halving_preserves_sole_user(self):
|
||||
"""#6196: 对半砍不应丢掉唯一的 user 消息。"""
|
||||
truncator = ContextTruncator()
|
||||
msgs = self._build_tool_chain(20)
|
||||
result = truncator.truncate_by_halving(msgs)
|
||||
roles = [m.role for m in result]
|
||||
assert "user" in roles, "唯一的 user 消息被丢掉了"
|
||||
|
||||
def test_truncate_by_turns_preserves_sole_user(self):
|
||||
"""#6196: keep_most_recent_turns 也不应丢掉唯一的 user 消息。"""
|
||||
truncator = ContextTruncator()
|
||||
msgs = self._build_tool_chain(20)
|
||||
result = truncator.truncate_by_turns(
|
||||
msgs, keep_most_recent_turns=3, drop_turns=1
|
||||
)
|
||||
roles = [m.role for m in result]
|
||||
assert "user" in roles, "唯一的 user 消息被丢掉了"
|
||||
|
||||
def test_drop_oldest_heavy_drops_still_has_user(self):
|
||||
"""#6196: 大量 drop 也不会丢 user。"""
|
||||
truncator = ContextTruncator()
|
||||
msgs = self._build_tool_chain(30)
|
||||
result = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=10)
|
||||
roles = [m.role for m in result]
|
||||
assert "user" in roles
|
||||
|
||||
def test_normal_multi_user_not_affected(self):
|
||||
"""正常多 user 对话不受影响。"""
|
||||
truncator = ContextTruncator()
|
||||
msgs = self.create_messages(20, include_system=True)
|
||||
result_before = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=2)
|
||||
# 多 user 场景下截断后仍有 user
|
||||
roles = [m.role for m in result_before]
|
||||
assert "user" in roles
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import httpx
|
||||
|
||||
import astrbot.core.provider.sources.anthropic_source as anthropic_source
|
||||
import astrbot.core.provider.sources.kimi_code_source as kimi_code_source
|
||||
|
||||
|
||||
class _FakeAsyncAnthropic:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def test_anthropic_provider_injects_custom_headers_into_http_client(monkeypatch):
|
||||
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)
|
||||
|
||||
provider = anthropic_source.ProviderAnthropic(
|
||||
provider_config={
|
||||
"id": "anthropic-test",
|
||||
"type": "anthropic_chat_completion",
|
||||
"model": "claude-test",
|
||||
"key": ["test-key"],
|
||||
"custom_headers": {
|
||||
"User-Agent": "custom-agent/1.0",
|
||||
"X-Test-Header": 123,
|
||||
},
|
||||
},
|
||||
provider_settings={},
|
||||
)
|
||||
|
||||
assert provider.custom_headers == {
|
||||
"User-Agent": "custom-agent/1.0",
|
||||
"X-Test-Header": "123",
|
||||
}
|
||||
assert isinstance(provider.client.kwargs["http_client"], httpx.AsyncClient)
|
||||
assert provider.client.kwargs["http_client"].headers["User-Agent"] == "custom-agent/1.0"
|
||||
assert provider.client.kwargs["http_client"].headers["X-Test-Header"] == "123"
|
||||
|
||||
|
||||
def test_kimi_code_provider_sets_defaults_and_preserves_custom_headers(monkeypatch):
|
||||
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)
|
||||
|
||||
provider = kimi_code_source.ProviderKimiCode(
|
||||
provider_config={
|
||||
"id": "kimi-code",
|
||||
"type": "kimi_code_chat_completion",
|
||||
"key": ["test-key"],
|
||||
"custom_headers": {"X-Trace-Id": "trace-1"},
|
||||
},
|
||||
provider_settings={},
|
||||
)
|
||||
|
||||
assert provider.base_url == kimi_code_source.KIMI_CODE_API_BASE
|
||||
assert provider.get_model() == kimi_code_source.KIMI_CODE_DEFAULT_MODEL
|
||||
assert provider.custom_headers == {
|
||||
"User-Agent": kimi_code_source.KIMI_CODE_USER_AGENT,
|
||||
"X-Trace-Id": "trace-1",
|
||||
}
|
||||
assert provider.client.kwargs["http_client"].headers["User-Agent"] == (
|
||||
kimi_code_source.KIMI_CODE_USER_AGENT
|
||||
)
|
||||
assert provider.client.kwargs["http_client"].headers["X-Trace-Id"] == "trace-1"
|
||||
|
||||
|
||||
def test_kimi_code_provider_restores_required_user_agent_when_blank(monkeypatch):
|
||||
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)
|
||||
|
||||
provider = kimi_code_source.ProviderKimiCode(
|
||||
provider_config={
|
||||
"id": "kimi-code",
|
||||
"type": "kimi_code_chat_completion",
|
||||
"key": ["test-key"],
|
||||
"custom_headers": {"User-Agent": " "},
|
||||
},
|
||||
provider_settings={},
|
||||
)
|
||||
|
||||
assert provider.custom_headers == {
|
||||
"User-Agent": kimi_code_source.KIMI_CODE_USER_AGENT,
|
||||
}
|
||||
Reference in New Issue
Block a user