Compare commits

...

43 Commits

Author SHA1 Message Date
Soulter 16ec462abd feat: WebUI ProviderPage 添加服务提供商会话隔离设置功能 2025-06-11 00:51:18 +08:00
Soulter ca55465d3c chore: bump to 3.5.15 2025-06-11 00:32:46 +08:00
Soulter 7098c98dde fix: 修复 Windows 下部署项目时可能出现的 UnicodeDecodeError
fixes: #1548
2025-06-11 00:25:14 +08:00
Soulter f56355da89 perf: 分段回复时,仅在输出的第一句话带上回复/引用
fixes: #521
2025-06-11 00:06:14 +08:00
Soulter 422160debd feat: 支持配置是否忽略@全体成员
fixes: #292
2025-06-10 23:55:50 +08:00
Soulter 8062cf406a fix: 优化配置完整性检查,同时保证配置项顺序的一致性 2025-06-10 23:30:58 +08:00
Soulter 0e802232ec feat: 新配置项,支持配置只@触发等待时是否回复 2025-06-10 23:29:45 +08:00
Soulter f650a9205d perf(webui): 优化手机端的显示 2025-06-10 22:43:58 +08:00
Soulter c85dbb2347 fix: 修复某些情况下,会话控制无效的问题 2025-06-10 22:26:11 +08:00
Soulter a6a79128c8 chore: bump to v3.5.15 2025-06-10 22:18:05 +08:00
Soulter 42839627e8 fix: 修复在设置了 GitHub 加速地址后,插件无法更新的问题 2025-06-10 22:12:46 +08:00
Soulter 267e68a894 chore: bump docker image python version to 3.11 2025-06-10 21:40:20 +08:00
Soulter b32b444438 Merge pull request #1776 from AstrBotDevs/feat-webchat-title
Feature: 支持重命名和自动生成 WebChat title;WebChat Route 和 UI 优化;支持 WebChatBox
2025-06-10 21:34:17 +08:00
Soulter 522d0f8313 chore: ts lint 2025-06-10 21:33:53 +08:00
Soulter 5715e5de67 chore: fix ts lint 2025-06-10 21:28:06 +08:00
Soulter cc6b05e8b3 fix: remove fallback for returnUrl in AuthLogin.vue 2025-06-10 21:25:58 +08:00
Soulter 417747d5d0 feat: handle unauthorized access by redirecting to login page in ChatPage 2025-06-10 21:21:38 +08:00
Soulter a34f439226 fix: update summary output condition and adjust max-width in ChatBoxPage 2025-06-10 18:36:26 +08:00
Soulter b7ca014fd0 feat: enhance routing to support chatbox and improve path handling in ChatPage 2025-06-10 15:45:06 +08:00
Soulter fa098d585a feat: add conversation detail routing and handle direct navigation in ChatPage 2025-06-10 15:39:26 +08:00
Soulter c35a14e3ec fix: adjust padding and clean up unused code in ChatPage.vue 2025-06-10 15:06:33 +08:00
Soulter 60651736a5 feat: chatbox page 2025-06-10 15:02:18 +08:00
Soulter 581f9b7bd3 fix: typo fix
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-06-10 13:02:30 +08:00
Soulter 124eb04807 Merge pull request #1773 from AstrBotDevs/feat-seperate-provider
Feature: 支持对提供商会话隔离
2025-06-10 12:59:42 +08:00
Soulter 1d561da7fb style: clean code
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-10 12:59:20 +08:00
Soulter 16e3cd0784 fix: get_using_stt_provider is fetching using ProviderType.TEXT_TO_SPEECH but should use ProviderType.SPEECH_TO_TEXT for STT isolation.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-10 12:58:39 +08:00
Soulter a6d91933dc feat: 支持自动生成webchat title 2025-06-10 10:58:49 +08:00
Raven95676 445c40f758 chore: update version 2025-06-10 10:29:31 +08:00
鸦羽 725a841a3b Merge pull request #1767 from AstrBotDevs/fix/1678
Fix: 调整Gemini原生工具启用行为
2025-06-10 08:22:41 +08:00
鸦羽 f77c453843 fix: clean code 2025-06-10 00:20:35 +00:00
Soulter ba6718d5bc Merge pull request #1759 from Flartiny/dev
Feature: Add GreedyStr parameter support for commands
2025-06-10 00:06:34 +08:00
Soulter cdb7a1b3fa style: merge else if into elif 2025-06-09 23:54:51 +08:00
Soulter a03c79b89d style: use named expression 2025-06-09 23:51:54 +08:00
Soulter 98800d3426 fix(typo): "seperate_provider" -> "separate_provider" 2025-06-09 23:50:31 +08:00
Soulter a616adaac4 fix: update provider manager set_provider() 2025-06-09 23:46:44 +08:00
Soulter ffb5605c99 fix: default tts provider selection
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-06-09 23:38:15 +08:00
Soulter 621b556856 feat: 支持对提供商会话隔离
fixes: #1762 #602 #479
2025-06-09 23:33:00 +08:00
Soulter a3ffecbb2a feat: add support for gemini_embedding provider 2025-06-09 14:43:05 +08:00
鸦羽 e79487dd5f fix: add missing config 2025-06-09 05:03:15 +00:00
鸦羽 7fe1c1ec89 feat: add URL context feature to Gemini model configuration 2025-06-09 04:54:24 +00:00
鸦羽 33b64ddf39 feat: enhance tool selection logic for Gemini model versions 2025-06-09 03:55:59 +00:00
Flartiny 9713f96401 feat: Add greedy parameter support for commands 2025-06-07 10:32:31 +08:00
Soulter 98d2e9bd27 chore: stage 2025-06-05 23:30:18 +08:00
31 changed files with 1168 additions and 288 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.10-slim
FROM python:3.11-slim
WORKDIR /AstrBot
COPY . /AstrBot/
+40 -8
View File
@@ -83,29 +83,61 @@ class AstrBotConfig(dict):
return conf
def check_config_integrity(self, refer_conf: Dict, conf: Dict, path=""):
"""检查配置完整性,如果有新的配置项则返回 True"""
"""检查配置完整性,如果有新的配置项或顺序不一致则返回 True"""
has_new = False
# 创建一个新的有序字典以保持参考配置的顺序
new_conf = {}
# 先按照参考配置的顺序添加配置项
for key, value in refer_conf.items():
if key not in conf:
# logger.info(f"检查到配置项 {path + "." + key if path else key} 不存在,插入默认值 {value}")
# 配置项不存在,插入默认值
path_ = path + "." + key if path else key
logger.info(f"检查到配置项 {path_} 不存在,已插入默认值 {value}")
conf[key] = value
new_conf[key] = value
has_new = True
else:
if conf[key] is None:
conf[key] = value
# 配置项为 None,使用默认值
new_conf[key] = value
has_new = True
elif isinstance(value, dict):
has_new |= self.check_config_integrity(
value, conf[key], path + "." + key if path else key
)
# 递归检查子配置项
if not isinstance(conf[key], dict):
# 类型不匹配,使用默认值
new_conf[key] = value
has_new = True
else:
# 递归检查并同步顺序
child_has_new = self.check_config_integrity(
value, conf[key], path + "." + key if path else key
)
new_conf[key] = conf[key]
has_new |= child_has_new
else:
# 直接使用现有配置
new_conf[key] = conf[key]
# 检查是否存在参考配置中没有的配置项
for key in list(conf.keys()):
if key not in refer_conf:
path_ = path + "." + key if path else key
logger.info(f"检查到配置项 {path_} 不存在,将从当前配置中删除")
del conf[key]
has_new = True
# 顺序不一致也算作变更
if list(conf.keys()) != list(new_conf.keys()):
if path:
logger.info(f"检查到配置项 {path} 的子项顺序不一致,已重新排序")
else:
logger.info("检查到配置项顺序不一致,已重新排序")
has_new = True
# 更新原始配置
conf.clear()
conf.update(new_conf)
return has_new
def save_config(self, replace_config: Dict = None):
+37 -6
View File
@@ -5,7 +5,7 @@
import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "3.5.14"
VERSION = "3.5.15"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
# 默认配置
@@ -40,12 +40,15 @@ DEFAULT_CONFIG = {
},
"no_permission_reply": True,
"empty_mention_waiting": True,
"empty_mention_waiting_need_reply": True,
"friend_message_needs_wake_prefix": False,
"ignore_bot_self_message": False,
"ignore_at_all": False,
},
"provider": [],
"provider_settings": {
"enable": True,
"default_provider_id": "",
"wake_prefix": "",
"web_search": False,
"web_search_link": False,
@@ -57,6 +60,7 @@ DEFAULT_CONFIG = {
"dequeue_context_length": 1,
"streaming_response": False,
"streaming_segmented": False,
"separate_provider": False,
},
"provider_stt_settings": {
"enable": False,
@@ -355,9 +359,14 @@ CONFIG_METADATA_2 = {
"hint": "启用后,当用户没有权限执行某个操作时,机器人会回复一条消息。",
},
"empty_mention_waiting": {
"description": "只 @ 机器人是否触发等待回复",
"description": "只 @ 机器人是否触发等待",
"type": "bool",
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待回复,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
},
"empty_mention_waiting_need_reply": {
"description": "只 @ 机器人触发等待时是否需要回复提醒",
"type": "bool",
"hint": "在上面一个配置项中,如果启用了触发等待,启用此项后,机器人会使用 LLM 生成一条回复。否则,将不回复而只是等待。",
},
"friend_message_needs_wake_prefix": {
"description": "私聊消息是否需要唤醒前缀",
@@ -369,6 +378,11 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "某些平台如 gewechat 会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人",
},
"ignore_at_all": {
"description": "是否忽略 @ 全体成员",
"type": "bool",
"hint": "启用后,机器人会忽略 @ 全体成员 的消息事件。",
},
"segmented_reply": {
"description": "分段回复",
"type": "object",
@@ -620,6 +634,7 @@ CONFIG_METADATA_2 = {
"gm_resp_image_modal": False,
"gm_native_search": False,
"gm_native_coderunner": False,
"gm_url_context": False,
"gm_safety_settings": {
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
@@ -1024,6 +1039,12 @@ CONFIG_METADATA_2 = {
"hint": "启用后所有函数工具将全部失效",
"obvious_hint": True,
},
"gm_url_context": {
"description": "启用URL上下文功能",
"type": "bool",
"hint": "启用后所有函数工具将全部失效",
"obvious_hint": True,
},
"gm_safety_settings": {
"description": "安全过滤器",
"type": "object",
@@ -1379,9 +1400,19 @@ CONFIG_METADATA_2 = {
"enable": {
"description": "启用大语言模型聊天",
"type": "bool",
"hint": "如需切换大语言模型提供商,请使用 `/provider` 命令。",
"hint": "如需切换大语言模型提供商,请使用 /provider 命令。",
"obvious_hint": True,
},
"separate_provider": {
"description": "提供商会话隔离",
"type": "bool",
"hint": "启用后,每个会话支持独立选择文本生成、STT、TTS 等提供商。如果会话在使用 /provider 指令时提示无权限,可以将会话加入管理员名单或者使用 /alter_cmd provider member 将指令设为非管理员指令。",
},
"default_provider_id": {
"description": "默认模型提供商 ID",
"type": "string",
"hint": "可选。每个聊天会话的默认提供商 ID。",
},
"wake_prefix": {
"description": "LLM 聊天额外唤醒前缀",
"type": "string",
@@ -1494,7 +1525,7 @@ CONFIG_METADATA_2 = {
"obvious_hint": True,
},
"provider_id": {
"description": "提供商 ID,不填则默认第一个STT提供商",
"description": "提供商 ID",
"type": "string",
"hint": "语音转文本提供商 ID。如果不填写将使用载入的第一个提供商。",
},
@@ -1511,7 +1542,7 @@ CONFIG_METADATA_2 = {
"obvious_hint": True,
},
"provider_id": {
"description": "提供商 ID,不填则默认第一个TTS提供商",
"description": "提供商 ID",
"type": "string",
"hint": "文本转语音提供商 ID。如果不填写将使用载入的第一个提供商。",
},
+3 -1
View File
@@ -11,7 +11,9 @@ class SQLiteDatabase(BaseDatabase):
super().__init__()
self.db_path = db_path
with open(os.path.dirname(__file__) + "/sqlite_init.sql", "r") as f:
with open(
os.path.dirname(__file__) + "/sqlite_init.sql", "r", encoding="utf-8"
) as f:
sql = f.read()
# 初始化数据库
@@ -43,9 +43,8 @@ class PreProcessStage(Stage):
# STT
if self.stt_settings.get("enable", False):
# TODO: 独立
stt_provider = (
self.plugin_manager.context.provider_manager.curr_stt_provider_inst
)
ctx = self.plugin_manager.context
stt_provider = ctx.get_using_stt_provider(event.unified_msg_origin)
if not stt_provider:
return
message_chain = event.get_messages()
@@ -33,6 +33,7 @@ from mcp.types import (
TextResourceContents,
BlobResourceContents,
)
from astrbot.core import web_chat_back_queue
class LLMRequestSubStage(Stage):
@@ -70,8 +71,8 @@ class LLMRequestSubStage(Stage):
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
logger.debug("未启用 LLM 能力,跳过处理。")
return
provider = self.ctx.plugin_manager.context.get_using_provider()
umo = event.unified_msg_origin
provider = self.ctx.plugin_manager.context.get_using_provider(umo=umo)
if provider is None:
return
@@ -287,7 +288,66 @@ class LLMRequestSubStage(Stage):
if img_b64 := event.get_extra("tool_call_img_respond"):
await event.send(MessageChain(chain=[Image.fromBase64(img_b64)]))
event.set_extra("tool_call_img_respond", None)
yield
if event.get_platform_name() == "webchat":
# 异步处理 WebChat 特殊情况
asyncio.create_task(self._handle_webchat(event, req))
async def _handle_webchat(self, event: AstrMessageEvent, req: ProviderRequest):
"""处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
conversation = await self.conv_manager.get_conversation(
event.unified_msg_origin, req.conversation.cid
)
if conversation and not req.conversation.title:
messages = json.loads(conversation.history)
latest_pair = messages[-2:]
if not latest_pair:
return
provider = self.ctx.plugin_manager.context.get_using_provider()
cleaned_text = "User: " + latest_pair[0].get("content", "").strip()
# if len(latest_pair) > 1:
# cleaned_text += (
# "\nAssistant: " + latest_pair[1].get("content", "").strip()
# )
logger.debug(f"WebChat 对话标题生成请求,清理后的文本: {cleaned_text}")
llm_resp = await provider.text_chat(
system_prompt="You are expert in summarizing user's query.",
prompt=(
f"Please summarize the following query of user:\n"
f"{cleaned_text}\n"
"Only output the summary within 10 words, DO NOT INCLUDE any other text."
"You must use the same language as the user."
"If you think the dialog is too short to summarize, only output a special mark: `None`"
),
)
if llm_resp and llm_resp.completion_text:
logger.debug(
f"WebChat 对话标题生成响应: {llm_resp.completion_text.strip()}"
)
title = llm_resp.completion_text.strip()
if not title or "None" == title:
return
await self.conv_manager.update_conversation_title(
event.unified_msg_origin, title=title
)
# 由于 WebChat 平台特殊性,其有两个对话,因此我们要更新两个对话的标题
# webchat adapter 中,session_id 的格式是 f"webchat!{username}!{cid}"
# TODO: 优化 WebChat 适配器的对话管理
if event.session_id:
username, cid = event.session_id.split("!")[1:3]
db_helper = self.ctx.plugin_manager.context._db
db_helper.update_conversation_title(
user_id=username,
cid=cid,
title=title,
)
web_chat_back_queue.put_nowait(
{
"type": "update_title",
"cid": cid,
"data": title,
}
)
async def _handle_llm_response(
self,
+1
View File
@@ -191,6 +191,7 @@ class RespondStage(Stage):
await asyncio.sleep(i)
try:
await event.send(MessageChain([*decorated_comps, comp]))
decorated_comps = [] # 清空已发送的装饰组件
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
break
@@ -169,8 +169,8 @@ class ResultDecorateStage(Stage):
result.chain = new_chain
# TTS
tts_provider = (
self.ctx.plugin_manager.context.provider_manager.curr_tts_provider_inst
tts_provider = self.ctx.plugin_manager.context.get_using_tts_provider(
event.unified_msg_origin
)
if (
self.ctx.astrbot_config["provider_tts_settings"]["enable"]
+6 -4
View File
@@ -4,7 +4,7 @@ from astrbot import logger
from typing import Union, AsyncGenerator
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
from astrbot.core.message.components import At
from astrbot.core.message.components import At, AtAll
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
from astrbot.core.star.filter.permission import PermissionTypeFilter
@@ -39,6 +39,9 @@ class WakingCheckStage(Stage):
self.ignore_bot_self_message = self.ctx.astrbot_config["platform_settings"].get(
"ignore_bot_self_message", False
)
self.ignore_at_all = self.ctx.astrbot_config["platform_settings"].get(
"ignore_at_all", False
)
async def process(
self, event: AstrMessageEvent
@@ -79,10 +82,9 @@ class WakingCheckStage(Stage):
if not is_wake:
# 检查是否有 at 消息
for message in messages:
if isinstance(message, At) and (
if (isinstance(message, At) and (
str(message.qq) == str(event.get_self_id())
or str(message.qq) == "all"
):
)) or (isinstance(message, AtAll) and not self.ignore_at_all):
is_wake = True
event.is_wake = True
wake_prefix = ""
@@ -221,6 +221,9 @@ class AiocqhttpAdapter(Platform):
a = None
if t == "text":
current_text = "".join(m["data"]["text"] for m in m_group).strip()
if not current_text:
# 如果文本段为空,则跳过
continue
message_str += current_text
a = ComponentTypes[t](text=current_text) # noqa: F405
abm.message.append(a)
+64 -21
View File
@@ -18,13 +18,6 @@ class ProviderManager:
self.persona_configs: list = config.get("persona", [])
self.astrbot_config = config
self.selected_provider_id = sp.get("curr_provider")
self.selected_stt_provider_id = self.provider_stt_settings.get("provider_id")
self.selected_tts_provider_id = self.provider_settings.get("provider_id")
# self.provider_enabled = self.provider_settings.get("enable", False)
# self.stt_enabled = self.provider_stt_settings.get("enable", False)
# self.tts_enabled = self.provider_tts_settings.get("enable", False)
# 人格情景管理
# 目前没有拆成独立的模块
self.default_persona_name = self.provider_settings.get(
@@ -103,14 +96,13 @@ class ProviderManager:
self.inst_map = {}
"""Provider 实例映射. key: provider_id, value: Provider 实例"""
self.llm_tools = llm_tools
self.default_provider_inst: Provider = None
"""默认的 Provider 实例。第 0 个或者用户以前指定的 Provider 实例"""
self.curr_provider_inst: Provider = None
"""当前使用的 Provider 实例"""
"""默认的 Provider 实例"""
self.curr_stt_provider_inst: STTProvider = None
"""当前使用的 Speech To Text Provider 实例"""
"""默认的 Speech To Text Provider 实例"""
self.curr_tts_provider_inst: TTSProvider = None
"""当前使用的 Text To Speech Provider 实例"""
"""默认的 Text To Speech Provider 实例"""
self.db_helper = db_helper
# kdb(experimental)
@@ -119,13 +111,57 @@ class ProviderManager:
if kdb_cfg and len(kdb_cfg):
self.curr_kdb_name = list(kdb_cfg.keys())[0]
async def set_provider(
self, provider_id: str, provider_type: ProviderType, umo: str = None
):
"""设置提供商。
Args:
provider_id (str): 提供商 ID。
provider_type (ProviderType): 提供商类型。
umo (str, optional): 用户会话 ID,用于提供商会话隔离。当用户启用了提供商会话隔离时此参数才生效。
"""
if provider_id not in self.inst_map:
raise ValueError(f"提供商 {provider_id} 不存在,无法设置。")
if umo and self.provider_settings["separate_provider"]:
perf = sp.get("session_provider_perf", {})
session_perf = perf.get(umo, {})
session_perf[provider_type.value] = provider_id
perf[umo] = session_perf
sp.put("session_provider_perf", perf)
return
# 不启用提供商会话隔离模式的情况
self.curr_provider_inst = self.inst_map[provider_id]
if provider_type == ProviderType.TEXT_TO_SPEECH:
sp.put("curr_provider_tts", provider_id)
elif provider_type == ProviderType.SPEECH_TO_TEXT:
sp.put("curr_provider_stt", provider_id)
elif provider_type == ProviderType.CHAT_COMPLETION:
sp.put("curr_provider", provider_id)
async def initialize(self):
# 逐个初始化提供商
for provider_config in self.providers_config:
await self.load_provider(provider_config)
self.default_provider_inst = self.inst_map.get(self.selected_provider_id)
if not self.default_provider_inst and self.provider_insts:
self.default_provider_inst = self.provider_insts[0]
# 设置默认提供商
self.curr_provider_inst = self.inst_map.get(
self.provider_settings.get("default_provider_id")
)
if not self.curr_provider_inst and self.provider_insts:
self.curr_provider_inst = self.provider_insts[0]
self.curr_stt_provider_inst = self.inst_map.get(
self.provider_stt_settings.get("provider_id")
)
if not self.curr_stt_provider_inst and self.stt_provider_insts:
self.curr_stt_provider_inst = self.stt_provider_insts[0]
self.curr_tts_provider_inst = self.inst_map.get(
self.provider_tts_settings.get("provider_id")
)
if not self.curr_tts_provider_inst and self.tts_provider_insts:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
# 初始化 MCP Client 连接
asyncio.create_task(
@@ -217,6 +253,10 @@ class ProviderManager:
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"
@@ -248,7 +288,10 @@ class ProviderManager:
await inst.initialize()
self.stt_provider_insts.append(inst)
if self.selected_stt_provider_id == provider_config["id"]:
if (
self.provider_stt_settings.get("provider_id")
== provider_config["id"]
):
self.curr_stt_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。"
@@ -266,7 +309,7 @@ class ProviderManager:
await inst.initialize()
self.tts_provider_insts.append(inst)
if self.selected_tts_provider_id == provider_config["id"]:
if self.provider_settings.get("provider_id") == provider_config["id"]:
self.curr_tts_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。"
@@ -288,7 +331,10 @@ class ProviderManager:
await inst.initialize()
self.provider_insts.append(inst)
if self.selected_provider_id == provider_config["id"]:
if (
self.provider_settings.get("default_provider_id")
== provider_config["id"]
):
self.curr_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。"
@@ -326,7 +372,6 @@ class ProviderManager:
self.curr_provider_inst = None
elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
self.curr_provider_inst = self.provider_insts[0]
self.selected_provider_id = self.curr_provider_inst.meta().id
logger.info(
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。"
)
@@ -335,7 +380,6 @@ class ProviderManager:
self.curr_stt_provider_inst = None
elif self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0:
self.curr_stt_provider_inst = self.stt_provider_insts[0]
self.selected_stt_provider_id = self.curr_stt_provider_inst.meta().id
logger.info(
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。"
)
@@ -344,7 +388,6 @@ class ProviderManager:
self.curr_tts_provider_inst = None
elif self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
self.selected_tts_provider_id = self.curr_tts_provider_inst.meta().id
logger.info(
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。"
)
+53 -11
View File
@@ -141,24 +141,66 @@ class ProviderGoogleGenAI(Provider):
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
modalities = ["Text"]
tool_list = None
tool_list = []
model_name = self.get_model()
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False)
if native_coderunner:
tool_list = [types.Tool(code_execution=types.ToolCodeExecution())]
if native_search:
logger.warning("已启用代码执行工具,搜索工具将被忽略")
if tools:
logger.warning("已启用代码执行工具,函数工具将被忽略")
elif native_search:
tool_list = [types.Tool(google_search=types.GoogleSearch())]
if tools:
logger.warning("已启用搜索工具,函数工具将被忽略")
if "gemini-2.5" in model_name:
if native_coderunner:
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
if native_search:
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
if url_context:
logger.warning(
"代码执行工具与URL上下文工具互斥,已忽略URL上下文工具"
)
else:
if native_search:
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
if url_context:
if hasattr(types, "UrlContext"):
tool_list.append(types.Tool(url_context=types.UrlContext()))
else:
logger.warning(
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包"
)
elif "gemini-2.0-lite" in model_name:
if native_coderunner or native_search or url_context:
logger.warning(
"gemini-2.0-lite 不支持代码执行、搜索工具和URL上下文,将忽略这些设置"
)
tool_list = None
else:
if native_coderunner:
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
if native_search:
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
elif native_search:
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
if url_context and not native_coderunner:
if hasattr(types, "UrlContext"):
tool_list.append(types.Tool(url_context=types.UrlContext()))
else:
logger.warning(
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包"
)
if not tool_list:
tool_list = None
if tools and tool_list:
logger.warning("已启用原生工具,函数工具将被忽略")
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
tool_list = [
types.Tool(function_declarations=func_desc["function_declarations"])
]
return types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=temperature,
+28 -5
View File
@@ -3,6 +3,7 @@ from typing import List, Union
from astrbot.core import sp
from astrbot.core.provider.provider import Provider, TTSProvider, STTProvider
from astrbot.core.provider.entities import ProviderType
from astrbot.core.db import BaseDatabase
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.provider.func_tool_manager import FuncCall
@@ -140,24 +141,46 @@ class Context:
"""获取所有用于 STT 任务的 Provider。"""
return self.provider_manager.stt_provider_insts
def get_using_provider(self) -> Provider:
def get_using_provider(self, umo: str = None) -> Provider:
"""
获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。
获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。
通过 /provider 指令切换。
Args:
umo(str): unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,则使用该会话偏好的提供商。
"""
if umo and self._config["provider_settings"]["separate_provider"]:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.CHAT_COMPLETION.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
return inst
return self.provider_manager.curr_provider_inst
def get_using_tts_provider(self) -> TTSProvider:
def get_using_tts_provider(self, umo: str = None) -> TTSProvider:
"""
获取当前使用的用于 TTS 任务的 Provider。
Args:
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
"""
if umo and self._config["provider_settings"]["separate_provider"]:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.TEXT_TO_SPEECH.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
return inst
return self.provider_manager.curr_tts_provider_inst
def get_using_stt_provider(self) -> STTProvider:
def get_using_stt_provider(self, umo: str = None) -> STTProvider:
"""
获取当前使用的用于 STT 任务的 Provider。
Args:
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
"""
if umo and self._config["provider_settings"]["separate_provider"]:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.SPEECH_TO_TEXT.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
return inst
return self.provider_manager.curr_stt_provider_inst
def get_config(self) -> AstrBotConfig:
+19 -1
View File
@@ -7,6 +7,9 @@ from astrbot.core.config import AstrBotConfig
from .custom_filter import CustomFilter
from ..star_handler import StarHandlerMetadata
class GreedyStr(str):
"""标记指令完成其他参数接收后的所有剩余文本。"""
pass
# 标准指令受到 wake_prefix 的制约。
class CommandFilter(HandlerFilter):
@@ -68,7 +71,22 @@ class CommandFilter(HandlerFilter):
) -> Dict[str, Any]:
"""将参数列表 params 根据 param_type 转换为参数字典。"""
result = {}
for i, (param_name, param_type_or_default_val) in enumerate(param_type.items()):
param_items = list(param_type.items())
for i, (param_name, param_type_or_default_val) in enumerate(param_items):
is_greedy = param_type_or_default_val is GreedyStr
if is_greedy:
# GreedyStr 必须是最后一个参数
if i != len(param_items) - 1:
raise ValueError(
f"参数 '{param_name}' (GreedyStr) 必须是最后一个参数。"
)
# 将剩余的所有部分合并成一个字符串
remaining_params = params[i:]
result[param_name] = " ".join(remaining_params)
break
# 没有 GreedyStr 的情况
if i >= len(params):
if (
isinstance(param_type_or_default_val, Type)
-4
View File
@@ -32,10 +32,6 @@ class PluginUpdator(RepoZipUpdator):
if not repo_url:
raise Exception(f"插件 {plugin.name} 没有指定仓库地址。")
if proxy:
proxy = proxy.removesuffix("/")
repo_url = f"{proxy}/{repo_url}"
plugin_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
logger.info(f"正在更新插件,路径: {plugin_path},仓库地址: {repo_url}")
+1
View File
@@ -148,6 +148,7 @@ class RepoZipUpdator:
release_url = releases[0]["zipball_url"]
if proxy:
proxy = proxy.rstrip("/")
release_url = f"{proxy}/{release_url}"
logger.info(
f"检查到设置了镜像站,将使用镜像站下载 {author}/{repo} 仓库源码: {release_url}"
+29 -12
View File
@@ -26,6 +26,7 @@ class ChatRoute(Route):
"/chat/conversations": ("GET", self.get_conversations),
"/chat/get_conversation": ("GET", self.get_conversation),
"/chat/delete_conversation": ("GET", self.delete_conversation),
"/chat/rename_conversation": ("POST", self.rename_conversation),
"/chat/get_file": ("GET", self.get_file),
"/chat/post_image": ("POST", self.post_image),
"/chat/post_file": ("POST", self.post_file),
@@ -100,7 +101,6 @@ class ChatRoute(Route):
file = post_data["file"]
filename = f"{str(uuid.uuid4())}"
print(file)
# 通过文件格式判断文件类型
if file.content_type.startswith("audio"):
filename += ".wav"
@@ -135,22 +135,24 @@ class ChatRoute(Route):
self.curr_user_cid[username] = conversation_id
await web_chat_queue.put((
username,
conversation_id,
{
"message": message,
"image_url": image_url, # list
"audio_url": audio_url,
},
))
await web_chat_queue.put(
(
username,
conversation_id,
{
"message": message,
"image_url": image_url, # list
"audio_url": audio_url,
},
)
)
# 持久化
conversation = self.db.get_conversation_by_user_id(username, conversation_id)
try:
history = json.loads(conversation.history)
except BaseException as e:
print(e)
logger.error(f"Failed to parse conversation history: {e}")
history = []
new_his = {"type": "user", "message": message}
if image_url:
@@ -204,6 +206,9 @@ class ChatRoute(Route):
if streaming and type != "end":
continue
if type == "update_title":
continue
if result_text:
conversation = self.db.get_conversation_by_user_id(
username, cid
@@ -211,7 +216,7 @@ class ChatRoute(Route):
try:
history = json.loads(conversation.history)
except BaseException as e:
print(e)
logger.error(f"Failed to parse conversation history: {e}")
history = []
history.append({"type": "bot", "message": result_text})
self.db.update_conversation(
@@ -249,6 +254,18 @@ class ChatRoute(Route):
self.db.new_conversation(username, conversation_id)
return Response().ok(data={"conversation_id": conversation_id}).__dict__
async def rename_conversation(self):
username = g.get("username", "guest")
post_data = await request.json
if "conversation_id" not in post_data or "title" not in post_data:
return Response().error("Missing key: conversation_id or title").__dict__
conversation_id = post_data["conversation_id"]
title = post_data["title"]
self.db.update_conversation_title(username, conversation_id, title=title)
return Response().ok(message="重命名成功!").__dict__
async def get_conversations(self):
username = g.get("username", "guest")
conversations = self.db.get_conversations(username)
+83 -27
View File
@@ -154,6 +154,7 @@ class ConfigRoute(Route):
) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.config: AstrBotConfig = core_lifecycle.astrbot_config
self.routes = {
"/config/get": ("GET", self.get_configs),
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
@@ -165,58 +166,94 @@ class ConfigRoute(Route):
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/llmtools": ("GET", self.get_llm_tools),
"/config/provider/check_status": ("GET", self.check_all_providers_status),
"/config/provider/check_status": ("GET", self.check_all_providers_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/get_session_seperate": (
"GET",
lambda: Response()
.ok({"enable": self.config["provider_settings"]["separate_provider"]})
.__dict__,
),
"/config/provider/set_session_seperate": (
"POST",
self.post_session_seperate,
),
}
self.register_routes()
async def _test_single_provider(self, provider):
async def _test_single_provider(self, provider):
"""辅助函数:测试单个 provider 的可用性"""
meta = provider.meta()
provider_name = provider.provider_config.get("id", "Unknown Provider")
logger.debug(f"Got provider meta: {meta}")
if not provider_name and meta:
if not provider_name and meta:
provider_name = meta.id
elif not provider_name:
elif not provider_name:
provider_name = "Unknown Provider"
status_info = {
"id": getattr(meta, 'id', 'Unknown ID'),
"model": getattr(meta, 'model', 'Unknown Model'),
"type": getattr(meta, 'type', 'Unknown Type'),
"id": getattr(meta, "id", "Unknown ID"),
"model": getattr(meta, "model", "Unknown Model"),
"type": getattr(meta, "type", "Unknown Type"),
"name": provider_name,
"status": "unavailable", # 默认为不可用
"status": "unavailable", # 默认为不可用
"error": None,
}
logger.debug(f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})")
logger.debug(
f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})"
)
try:
logger.debug(f"Sending 'Ping' to provider: {status_info['name']}")
response = await asyncio.wait_for(provider.text_chat(prompt="REPLY `PONG` ONLY"), timeout=45.0)
response = await asyncio.wait_for(
provider.text_chat(prompt="REPLY `PONG` ONLY"), timeout=45.0
)
logger.debug(f"Received response from {status_info['name']}: {response}")
# 只要 text_chat 调用成功返回一个 LLMResponse 对象 (即 response 不为 None),就认为可用
if response is not None:
status_info["status"] = "available"
response_text_snippet = ""
if hasattr(response, 'completion_text') and response.completion_text:
response_text_snippet = response.completion_text[:70] + "..." if len(response.completion_text) > 70 else response.completion_text
elif hasattr(response, 'result_chain') and response.result_chain:
if hasattr(response, "completion_text") and response.completion_text:
response_text_snippet = (
response.completion_text[:70] + "..."
if len(response.completion_text) > 70
else response.completion_text
)
elif hasattr(response, "result_chain") and response.result_chain:
try:
response_text_snippet = response.result_chain.get_plain_text()[:70] + "..." if len(response.result_chain.get_plain_text()) > 70 else response.result_chain.get_plain_text()
except:
response_text_snippet = (
response.result_chain.get_plain_text()[:70] + "..."
if len(response.result_chain.get_plain_text()) > 70
else response.result_chain.get_plain_text()
)
except Exception as _:
pass
logger.info(f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'")
logger.info(
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'"
)
else:
# 这个分支理论上不应该被走到,除非 text_chat 实现可能返回 None
status_info["error"] = "Test call returned None, but expected an LLMResponse object."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None.")
status_info["error"] = (
"Test call returned None, but expected an LLMResponse object."
)
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None."
)
except asyncio.TimeoutError:
status_info["error"] = "Connection timed out after 45 seconds during test call."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) timed out.")
status_info["error"] = (
"Connection timed out after 45 seconds during test call."
)
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) timed out."
)
except Exception as e:
error_message = str(e)
status_info["error"] = error_message
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}")
logger.debug(f"Traceback for {status_info['name']}:\n{traceback.format_exc()}")
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}"
)
logger.debug(
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}"
)
return status_info
async def check_all_providers_status(self):
@@ -225,7 +262,9 @@ class ConfigRoute(Route):
"""
logger.info("API call received: /config/provider/check_status")
try:
all_providers: typing.List = self.core_lifecycle.star_context.get_all_providers()
all_providers: typing.List = (
self.core_lifecycle.star_context.get_all_providers()
)
logger.debug(f"Found {len(all_providers)} providers to check.")
if not all_providers:
@@ -234,15 +273,17 @@ class ConfigRoute(Route):
tasks = [self._test_single_provider(p) for p in all_providers]
logger.debug(f"Created {len(tasks)} tasks for concurrent provider checks.")
results = await asyncio.gather(*tasks)
logger.info(f"Provider status check completed. Results: {results}")
return Response().ok(results).__dict__
return Response().ok(results).__dict__
except Exception as e:
logger.error(f"Critical error in check_all_providers_status: {str(e)}")
logger.error(traceback.format_exc())
return Response().error(f"检查 Provider 状态时发生严重错误: {str(e)}").__dict__
return (
Response().error(f"检查 Provider 状态时发生严重错误: {str(e)}").__dict__
)
async def get_configs(self):
# plugin_name 为空时返回 AstrBot 配置
@@ -252,6 +293,21 @@ class ConfigRoute(Route):
return Response().ok(await self._get_astrbot_config()).__dict__
return Response().ok(await self._get_plugin_config(plugin_name)).__dict__
async def post_session_seperate(self):
"""设置提供商会话隔离"""
post_config = await request.json
enable = post_config.get("enable", None)
if enable is None:
return Response().error("缺少参数 enable").__dict__
astrbot_config = self.core_lifecycle.astrbot_config
astrbot_config["provider_settings"]["separate_provider"] = enable
try:
astrbot_config.save_config()
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "设置成功~").__dict__
async def get_provider_config_list(self):
provider_type = request.args.get("provider_type", None)
if not provider_type:
+13
View File
@@ -0,0 +1,13 @@
# What's Changed
1. 修复:如果设置了 GitHub 加速地址,更新插件会报错
2. 修复:部分场景下,`只@触发等待` 配置项功能无效的问题
3. 新增:增加 `只@触发等待时是否回复` 配置项
4. 新增:**支持模型提供商使用时会话隔离(需要手动开启配置项:提供商会话隔离)**
5. 新增:Google Gemini 提供商支持 URL 上下文功能
6. 新增:优化 WebChat 的 UI 显示,WebChat 支持修改标题和自动生成标题,支持 WebChatBox
7. 新增:支持可配置是否忽略 @ 全体成员
8. 优化:WebUI 顶栏移动端显示
9. 优化:插件/AstrBot 配置项完整性检查的同时也保证**配置项相对顺序一致性**
10. 优化:perf: 分段回复时,仅在输出的第一句话带上回复/引用
11. 修复: Windows 下部署项目时可能出现的 UnicodeDecodeError。
@@ -254,7 +254,7 @@ commonStore.getStartTime();
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="hidden-lg-and-up text-secondary ms-3" color="lightsecondary" icon rounded="sm" variant="flat"
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="hidden-lg-and-up ms-3" color="lightsecondary" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
<v-icon>mdi-menu</v-icon>
</v-btn>
@@ -263,15 +263,15 @@ commonStore.getStartTime();
<v-icon>mdi-menu</v-icon>
</v-btn>
<div style="margin-left: 16px; display: flex; align-items: center; gap: 8px;">
<span style=" font-size: 24px; font-weight: 1000;">Astr<span style="font-weight: normal;">Bot</span>
</span>
<span style="font-size: 12px; color: var(--v-theme-secondaryText);">{{ botCurrVersion }}</span>
<div class="logo-container" :class="{'mobile-logo': $vuetify.display.xs}">
<span class="logo-text">Astr<span class="logo-text-light">Bot</span></span>
<span class="version-text hidden-xs">{{ botCurrVersion }}</span>
</div>
<v-spacer/>
<div class="mr-4">
<!-- 版本提示信息 - 在手机上隐藏 -->
<div class="mr-4 hidden-xs">
<small v-if="hasNewVersion">
AstrBot 有新版本
</small>
@@ -280,24 +280,28 @@ commonStore.getStartTime();
</small>
</div>
<v-btn size="small" @click="toggleDarkMode();" class="text-primary mr-2" color="var(--v-theme-surface)"
variant="flat" rounded="sm">
<!-- 明暗主题切换按钮 -->
<!-- 主题切换按钮 -->
<v-btn size="small" @click="toggleDarkMode();" class="action-btn"
color="var(--v-theme-surface)" variant="flat" rounded="sm">
<v-icon v-if="useCustomizerStore().uiTheme === 'PurpleThemeDark'">mdi-weather-night</v-icon>
<v-icon v-else>mdi-white-balance-sunny</v-icon>
</v-btn>
<v-dialog v-model="updateStatusDialog" width="1000">
<!-- 更新对话框 -->
<v-dialog v-model="updateStatusDialog" :width="$vuetify.display.smAndDown ? '100%' : '1000'" :fullscreen="$vuetify.display.xs">
<template v-slot:activator="{ props }">
<v-btn size="small" @click="checkUpdate(); getReleases(); getDevCommits();" class="text-primary mr-2"
color="var(--v-theme-surface)"
variant="flat" rounded="sm" v-bind="props">
更新
<v-btn size="small" @click="checkUpdate(); getReleases(); getDevCommits();" class="action-btn"
color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
<v-icon class="hidden-sm-and-up">mdi-update</v-icon>
<span class="hidden-xs">更新</span>
</v-btn>
</template>
<v-card>
<v-card-title>
<v-card-title class="mobile-card-title">
<span class="text-h5">更新 AstrBot</span>
<v-btn v-if="$vuetify.display.xs" icon @click="updateStatusDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-container>
@@ -308,10 +312,9 @@ commonStore.getStartTime();
<small style="margin-left: 4px;">{{ updateStatus }}</small>
</div>
<div
<div v-if="releaseMessage"
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="marked(releaseMessage)" class="markdown-content">
</div>
<div class="mb-4 mt-4">
@@ -422,11 +425,12 @@ commonStore.getStartTime();
</v-card>
</v-dialog>
<v-dialog v-model="dialog" persistent max-width="500">
<!-- 账户对话框 -->
<v-dialog v-model="dialog" persistent :max-width="$vuetify.display.xs ? '90%' : '500'">
<template v-slot:activator="{ props }">
<v-btn size="small" class="text-primary mr-4" color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
<v-icon class="mr-1">mdi-account</v-icon>
账户
<v-btn size="small" class="action-btn mr-4" color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
<v-icon>mdi-account</v-icon>
<span class="hidden-xs ml-1">账户</span>
</v-btn>
</template>
<v-card class="account-dialog">
@@ -582,4 +586,68 @@ commonStore.getStartTime();
.account-dialog .v-avatar:hover {
transform: scale(1.05);
}
/* 响应式布局样式 */
.logo-container {
margin-left: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.mobile-logo {
margin-left: 8px;
gap: 4px;
}
.logo-text {
font-size: 24px;
font-weight: 1000;
}
.logo-text-light {
font-weight: normal;
}
.version-text {
font-size: 12px;
color: var(--v-theme-secondaryText);
}
.action-btn {
margin-right: 6px;
}
/* 移动端对话框标题样式 */
.mobile-card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 移动端样式优化 */
@media (max-width: 600px) {
.logo-text {
font-size: 20px;
}
.action-btn {
margin-right: 4px;
min-width: 32px !important;
width: 32px;
}
.v-card-title {
padding: 12px 16px;
}
.v-card-text {
padding: 16px;
}
.v-tabs .v-tab {
padding: 0 10px;
font-size: 0.9rem;
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
const ChatBoxRoutes = {
path: '/chatbox',
component: () => import('@/layouts/blank/BlankLayout.vue'),
children: [
{
name: 'ChatBox',
path: '/chatbox',
component: () => import('@/views/ChatBoxPage.vue'),
children: [
{
path: ':conversationId',
name: 'ChatBoxDetail',
component: () => import('@/views/ChatBoxPage.vue'),
props: true
}
]
}
]
};
export default ChatBoxRoutes;
+9 -1
View File
@@ -81,7 +81,15 @@ const MainRoutes = {
{
name: 'Chat',
path: '/chat',
component: () => import('@/views/ChatPage.vue')
component: () => import('@/views/ChatPage.vue'),
children: [
{
path: ':conversationId',
name: 'ChatDetail',
component: () => import('@/views/ChatPage.vue'),
props: true
}
]
},
{
name: 'Settings',
+3 -1
View File
@@ -1,13 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router';
import MainRoutes from './MainRoutes';
import AuthRoutes from './AuthRoutes';
import ChatBoxRoutes from './ChatBoxRoutes';
import { useAuthStore } from '@/stores/auth';
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
MainRoutes,
AuthRoutes
AuthRoutes,
ChatBoxRoutes
]
});
+36
View File
@@ -0,0 +1,36 @@
<script setup>
import ChatPage from './ChatPage.vue';
</script>
<template>
<div style="height: 100%; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<div id="container">
<ChatPage chatbox-mode="true"></ChatPage>
</div>
</div>
</template>
<style scoped>
#container {
width: 100%;
height: 100%;
}
@media (min-width: 768px) {
#container {
min-width: 600px;
min-height: 370px;
max-width: 1100px;
max-height: 860px;
padding: 36px;
}
}
@media (max-width: 767px) {
#container {
width: 100%;
height: 100%;
padding: 0;
}
}
</style>
+387 -75
View File
@@ -1,35 +1,67 @@
<script setup>
import { router } from '@/router';
import axios from 'axios';
import { marked } from 'marked';
import { ref } from 'vue';
import { defineProps } from 'vue';
marked.setOptions({
breaks: true
});
const props = defineProps({
chatboxMode: {
type: Boolean,
default: false
}
});
</script>
<template>
<v-card class="chat-page-card">
<v-card-text class="chat-page-container">
<div class="chat-layout">
<div class="sidebar-panel">
<div style="padding: 16px; padding-top: 8px;">
<v-btn variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
prepend-icon="mdi-plus">创建对话</v-btn>
<div class="sidebar-panel" :class="{ 'sidebar-collapsed': sidebarCollapsed }"
@mouseenter="handleSidebarMouseEnter" @mouseleave="handleSidebarMouseLeave">
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;" v-if="props.chatboxMode">
<img width="50" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
<span v-if="!sidebarCollapsed" style="font-weight: 1000; font-size: 26px; margin-left: 8px;" class="text-secondary">AstrBot</span>
</div>
<div class="sidebar-collapse-btn-container">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text"
color="deep-purple">
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<div class="conversations-container">
<div style="padding: 16px; padding-top: 8px;">
<v-btn rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
v-if="!sidebarCollapsed" prepend-icon="mdi-plus">创建对话</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="newC" :disabled="!currCid" v-if="sidebarCollapsed"
elevation="0"></v-btn>
</div>
<div style="overflow-y: auto;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed">
<v-card class="conversation-list-card" v-if="conversations.length > 0" flat>
<v-list density="compact" nav class="conversation-list"
@update:selected="getConversationMessages">
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
rounded="lg" class="conversation-item" active-color="secondary">
<template v-slot:prepend>
<v-icon size="small" icon="mdi-message-text-outline"></v-icon>
<v-list-item-title v-if="!sidebarCollapsed" class="conversation-title">{{ item.title
|| '新对话' }}</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed" class="timestamp">{{
formatDate(item.updated_at)
}}</v-list-item-subtitle> -->
<template v-if="!sidebarCollapsed" v-slot:append>
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-title-btn"
@click.stop="showEditTitleDialog(item.cid, item.title)" />
</template>
<v-list-item-title class="conversation-title">新对话</v-list-item-title>
<v-list-item-subtitle class="timestamp">{{ formatDate(item.updated_at)
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
@@ -37,12 +69,14 @@ marked.setOptions({
<v-fade-transition>
<div class="no-conversations" v-if="conversations.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text">暂无对话历史</div>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded">
暂无对话历史</div>
</div>
</v-fade-transition>
</div>
<div class="sidebar-footer">
<div style="padding: 16px; padding-bottom: 0px;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed">
<div class="sidebar-section-title">
系统状态
</div>
@@ -53,7 +87,7 @@ marked.setOptions({
<v-icon :icon="status?.llm_enabled ? 'mdi-check-circle' : 'mdi-alert-circle'"
size="x-small"></v-icon>
</template>
LLM 服务
<span>LLM 服务</span>
</v-chip>
<v-chip class="status-chip" :color="status?.stt_enabled ? 'success' : 'grey-lighten-2'"
@@ -62,7 +96,7 @@ marked.setOptions({
<v-icon :icon="status?.stt_enabled ? 'mdi-check-circle' : 'mdi-alert-circle'"
size="x-small"></v-icon>
</template>
语音转文本
<span>语音转文本</span>
</v-chip>
</div>
@@ -76,6 +110,20 @@ marked.setOptions({
<!-- 右侧聊天内容区域 -->
<div class="chat-content-panel">
<div class="conversation-header fade-in">
<div class="conversation-header-content" v-if="currCid && getCurrentConversation">
<h2 class="conversation-header-title">{{ getCurrentConversation.title || '新对话' }}</h2>
<div class="conversation-header-time">{{ formatDate(getCurrentConversation.updated_at) }}</div>
</div>
<div class="conversation-header-actions">
<!-- router 推送到 /chatbox -->
<v-icon @click="router.push('/chatbox')" v-if="!props.chatboxMode"
class="fullscreen-icon">mdi-fullscreen</v-icon>
</div>
</div>
<v-divider v-if="currCid && getCurrentConversation" class="conversation-divider"></v-divider>
<div class="messages-container" ref="messageContainer">
<!-- 空聊天欢迎页 -->
<div class="welcome-container fade-in" v-if="messages.length == 0">
@@ -195,6 +243,22 @@ marked.setOptions({
</div>
</v-card-text>
</v-card>
<!-- 编辑对话标题对话框 -->
<v-dialog v-model="editTitleDialog" max-width="400">
<v-card>
<v-card-title class="dialog-title">编辑对话标题</v-card-title>
<v-card-text>
<v-text-field v-model="editingTitle" label="对话标题" variant="outlined" hide-details class="mt-2"
@keyup.enter="saveTitle" autofocus />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="editTitleDialog = false" color="grey-darken-1">取消</v-btn>
<v-btn text @click="saveTitle" color="primary">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
@@ -230,6 +294,68 @@ export default {
ctrlKeyLongPressThreshold: 300, //
mediaCache: {}, // Add a cache to store media blobs
//
editTitleDialog: false,
editingTitle: '',
editingCid: '',
//
sidebarCollapsed: false,
sidebarHovered: false,
sidebarHoverTimer: null,
sidebarHoverExpanded: false,
sidebarHoverDelay: 100, //
pendingCid: null, // Store pending conversation ID for route handling
}
},
computed: {
// Get the current conversation from the conversations array
getCurrentConversation() {
if (!this.currCid) return null;
return this.conversations.find(c => c.cid === this.currCid);
}
},
watch: {
// Watch for route changes to handle direct navigation to /chat/<cid>
'$route': {
immediate: true,
handler(to) {
console.log('Route changed:', to.path);
// Check if the route matches /chat/<cid> pattern
if (to.path.startsWith('/chat/') || to.path.startsWith('/chatbox/')) {
const pathCid = to.path.split('/')[2];
console.log('Path CID:', pathCid);
if (pathCid && pathCid !== this.currCid) {
// If conversations are already loaded
if (this.conversations.length > 0) {
const conversation = this.conversations.find(c => c.cid === pathCid);
if (conversation) {
this.getConversationMessages([pathCid]);
}
} else {
// Store the cid to be used after conversations are loaded
this.pendingCid = pathCid;
}
}
}
}
},
// Watch for conversations loaded to handle pending cid
conversations: {
handler(newConversations) {
if (this.pendingCid && newConversations.length > 0) {
const conversation = newConversations.find(c => c.cid === this.pendingCid);
if (conversation) {
this.getConversationMessages([this.pendingCid]);
this.pendingCid = null;
}
}
}
}
},
@@ -248,6 +374,12 @@ export default {
// keyup
document.addEventListener('keyup', this.handleInputKeyUp);
// localStorage
const savedCollapseState = localStorage.getItem('sidebarCollapsed');
if (savedCollapseState !== null) {
this.sidebarCollapsed = JSON.parse(savedCollapseState);
}
},
beforeUnmount() {
@@ -259,11 +391,86 @@ export default {
// keyup
document.removeEventListener('keyup', this.handleInputKeyUp);
//
if (this.sidebarHoverTimer) {
clearTimeout(this.sidebarHoverTimer);
}
// Cleanup blob URLs
this.cleanupMediaCache();
},
methods: {
//
toggleSidebar() {
if (this.sidebarHoverExpanded) {
this.sidebarHoverExpanded = false;
return
}
this.sidebarCollapsed = !this.sidebarCollapsed;
// localStorage
localStorage.setItem('sidebarCollapsed', JSON.stringify(this.sidebarCollapsed));
},
//
handleSidebarMouseEnter() {
if (!this.sidebarCollapsed) return;
this.sidebarHovered = true;
//
this.sidebarHoverTimer = setTimeout(() => {
if (this.sidebarHovered) {
this.sidebarHoverExpanded = true;
this.sidebarCollapsed = false;
}
}, this.sidebarHoverDelay);
},
handleSidebarMouseLeave() {
this.sidebarHovered = false;
//
if (this.sidebarHoverTimer) {
clearTimeout(this.sidebarHoverTimer);
this.sidebarHoverTimer = null;
}
if (this.sidebarHoverExpanded) {
this.sidebarCollapsed = true;
}
this.sidebarHoverExpanded = false;
},
//
showEditTitleDialog(cid, title) {
this.editingCid = cid;
this.editingTitle = title || ''; //
this.editTitleDialog = true;
},
//
saveTitle() {
if (!this.editingCid) return;
const trimmedTitle = this.editingTitle.trim();
axios.post('/api/chat/rename_conversation', {
conversation_id: this.editingCid,
title: trimmedTitle
})
.then(response => {
//
const conversation = this.conversations.find(c => c.cid === this.editingCid);
if (conversation) {
conversation.title = trimmedTitle;
}
this.editTitleDialog = false;
})
.catch(err => {
console.error('重命名对话失败:', err);
});
},
async getMediaFile(filename) {
if (this.mediaCache[filename]) {
return this.mediaCache[filename];
@@ -274,7 +481,7 @@ export default {
params: { filename },
responseType: 'blob'
});
const blobUrl = URL.createObjectURL(response.data);
this.mediaCache[filename] = blobUrl;
return blobUrl;
@@ -374,6 +581,14 @@ export default {
} else if (chunk_json.type === 'end') {
in_streaming = false;
continue;
} else if (chunk_json.type === 'update_title') {
//
const conversation = this.conversations.find(c => c.cid === chunk_json.cid);
if (conversation) {
conversation.title = chunk_json.data;
}
} else {
console.warn('未知数据类型:', chunk_json.type);
}
this.scrollToBottom();
}
@@ -472,13 +687,36 @@ export default {
getConversations() {
axios.get('/api/chat/conversations').then(response => {
this.conversations = response.data.data;
// If there's a pending conversation ID from the route
if (this.pendingCid) {
const conversation = this.conversations.find(c => c.cid === this.pendingCid);
if (conversation) {
this.getConversationMessages([this.pendingCid]);
this.pendingCid = null;
}
}
}).catch(err => {
if (err.response.status === 401) {
this.$router.push('/auth/login?redirect=/chatbox');
}
console.error(err);
});
},
getConversationMessages(cid) {
if (!cid[0])
return;
// Update the URL to reflect the selected conversation
if (this.$route.path !== `/chat/${cid[0]}` && this.$route.path !== `/chatbox/${cid[0]}`) {
if (this.$route.path.startsWith('/chatbox')) {
router.push(`/chatbox/${cid[0]}`);
} else {
router.push(`/chat/${cid[0]}`);
}
}
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => {
this.currCid = cid[0];
let message = JSON.parse(response.data.data.history);
@@ -511,17 +749,31 @@ export default {
});
},
async newConversation() {
await axios.get('/api/chat/new_conversation').then(response => {
this.currCid = response.data.data.conversation_id;
return axios.get('/api/chat/new_conversation').then(response => {
const cid = response.data.data.conversation_id;
this.currCid = cid;
// Update the URL to reflect the new conversation
if (this.$route.path.startsWith('/chatbox')) {
router.push(`/chatbox/${cid}`);
} else {
router.push(`/chat/${cid}`);
}
this.getConversations();
return cid;
}).catch(err => {
console.error(err);
throw err;
});
},
newC() {
this.currCid = '';
this.messages = [];
if (this.$route.path.startsWith('/chatbox')) {
router.push('/chatbox');
} else {
router.push('/chat');
}
},
formatDate(timestamp) {
@@ -550,7 +802,8 @@ export default {
async sendMessage() {
if (this.currCid == '') {
await this.newConversation();
const cid = await this.newConversation();
// URL is already updated in newConversation method
}
// Create a message object with actual URLs for display
@@ -601,15 +854,15 @@ export default {
audio_url: this.stagedAudioUrl ? [this.stagedAudioUrl] : [] // Already contains just filename
})
})
.then(response => {
this.prompt = '';
this.stagedImagesName = [];
this.stagedAudioUrl = "";
this.loadingChat = false;
})
.catch(err => {
console.error(err);
});
.then(response => {
this.prompt = '';
this.stagedImagesName = [];
this.stagedAudioUrl = "";
this.loadingChat = false;
})
.catch(err => {
console.error(err);
});
},
scrollToBottom() {
this.$nextTick(() => {
@@ -703,29 +956,43 @@ export default {
}
}
/* 添加淡入动画 */
@keyframes fadeInContent {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeInContent 0.2s ease-in forwards;
}
/* 聊天页面布局 */
/* todo: 聊天页面背景颜色有问题 */
.chat-page-card {
margin-bottom: 16px;
width: 100%;
height: 100%;
border-radius: 12px;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
}
.chat-page-container {
width: 100%;
height: calc(100vh - 120px);
height: 100%;
max-height: calc(100vh - 120px);
padding: 0;
}
.chat-layout {
height: 100%;
display: flex;
gap: 24px;
}
/* 侧边栏样式 - 优化版 */
.sidebar-panel {
max-width: 270px;
min-width: 240px;
@@ -736,57 +1003,34 @@ export default {
background-color: var(--v-theme-containerBg);
height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
/* 防止内容溢出 */
}
.sidebar-header {
padding: 16px;
/* 侧边栏折叠状态 */
.sidebar-collapsed {
max-width: 75px;
min-width: 75px;
transition: all 0.3s ease;
}
.conversations-container {
flex-grow: 1;
overflow-y: auto;
padding: 16px;
/* 当悬停展开时 */
.sidebar-collapsed.sidebar-hovered {
max-width: 270px;
min-width: 240px;
transition: all 0.3s ease;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.04);
/* 侧边栏折叠按钮 */
.sidebar-collapse-btn-container {
margin: 16px;
margin-bottom: 0px;
z-index: 10;
}
.sidebar-section-title {
font-size: 12px;
font-weight: 500;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-left: 4px;
}
.new-chat-btn {
width: 100%;
background-color: #673ab7 !important;
color: white !important;
font-weight: 500;
box-shadow: 0 2px 8px rgba(103, 58, 183, 0.25) !important;
transition: all 0.2s ease;
text-transform: none;
letter-spacing: 0.25px;
}
.new-chat-btn:hover {
background-color: #7e57c2 !important;
box-shadow: 0 4px 12px rgba(103, 58, 183, 0.3) !important;
transform: translateY(-1px);
}
.conversation-list-card {
border-radius: 8px;
box-shadow: none !important;
background-color: var(--v-theme-containerBg);
}
.conversation-list {
.sidebar-collapse-btn {
opacity: 0.6;
max-height: none;
overflow-y: visible;
padding: 0;
@@ -798,7 +1042,8 @@ export default {
transition: all 0.2s ease;
height: auto !important;
min-height: 56px;
padding: 8px 12px !important;
padding: 8px 16px !important;
position: relative;
}
.conversation-item:hover {
@@ -810,12 +1055,25 @@ export default {
font-size: 14px;
line-height: 1.3;
margin-bottom: 2px;
transition: opacity 0.25s ease;
}
.timestamp {
font-size: 11px;
color: var(--v-theme-secondaryText);
line-height: 1;
transition: opacity 0.25s ease;
}
.sidebar-section-title {
font-size: 12px;
font-weight: 500;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-left: 4px;
transition: opacity 0.25s ease;
}
.status-chips {
@@ -823,6 +1081,7 @@ export default {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
transition: opacity 0.25s ease;
}
.status-chip {
@@ -840,6 +1099,7 @@ export default {
letter-spacing: 0.25px;
font-size: 12px;
line-height: 1.2em;
transition: opacity 0.25s ease;
}
.delete-chat-btn:hover {
@@ -859,6 +1119,7 @@ export default {
.no-conversations-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
transition: opacity 0.25s ease;
}
/* 聊天内容区域 */
@@ -1152,4 +1413,55 @@ export default {
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
/* 对话框标题样式 */
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 8px;
}
/* 对话标题和时间样式 */
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 16px 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
}
.conversation-header-content {
display: flex;
flex-direction: column;
}
.conversation-header-title {
font-size: 18px;
font-weight: 600;
margin: 0;
color: var(--v-theme-primaryText);
}
.conversation-header-time {
font-size: 12px;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
.conversation-header-actions {
display: flex;
align-items: center;
}
.fullscreen-icon {
opacity: 0.7;
transition: opacity 0.2s;
cursor: pointer;
}
.fullscreen-icon:hover {
opacity: 1;
}
</style>
+79 -1
View File
@@ -8,7 +8,7 @@
<v-icon size="x-large" color="primary" class="me-2">mdi-creation</v-icon>服务提供商管理
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
管理AI服务提供商连接到不同的大语言模型
管理模型服务提供商
</p>
</v-col>
</v-row>
@@ -20,6 +20,9 @@
<span class="text-h6">服务提供商</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.provider?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-btn color="success" prepend-icon="mdi-cog" variant="tonal" class="me-2" @click="showSettingsDialog = true">
设置
</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true">
新增服务提供商
</v-btn>
@@ -253,6 +256,49 @@
</v-card>
</v-dialog>
<!-- 设置对话框 -->
<v-dialog v-model="showSettingsDialog" max-width="600px">
<v-card>
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
<v-icon color="white" class="me-2">mdi-cog</v-icon>
<span>服务提供商设置</span>
<v-spacer></v-spacer>
<v-btn icon variant="text" color="white" @click="showSettingsDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<v-list>
<v-list-item>
<v-switch
style="padding: 12px;"
v-model="sessionSeparationEnabled"
color="primary"
:loading="sessionSettingLoading"
@change="updateSessionSeparation"
hide-details
>
<template v-slot:label>
<div>
<div class="text-subtitle-1">启用提供商会话隔离</div>
<div class="text-caption text-medium-emphasis">不同会话将可独立选择文本生成TTSSTT 等服务提供商</div>
</div>
</template>
</v-switch>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showSettingsDialog = false">
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
@@ -285,6 +331,11 @@ export default {
metadata: {},
showProviderCfg: false,
//
showSettingsDialog: false,
sessionSeparationEnabled: false,
sessionSettingLoading: false,
newSelectedProviderName: '',
newSelectedProviderConfig: {},
updatingMode: false,
@@ -354,6 +405,7 @@ export default {
mounted() {
this.getConfig();
this.getSessionSeparationStatus();
},
methods: {
@@ -566,6 +618,32 @@ export default {
});
},
//
getSessionSeparationStatus() {
axios.get('/api/config/provider/get_session_seperate').then((res) => {
if (res.data && res.data.status === 'ok') {
this.sessionSeparationEnabled = res.data.data.enable;
}
}).catch((err) => {
this.showError(err.response?.data?.message || "获取会话隔离配置失败");
});
},
//
updateSessionSeparation() {
this.sessionSettingLoading = true;
axios.post('/api/config/provider/set_session_seperate', {
enable: this.sessionSeparationEnabled
}).then((res) => {
this.showSuccess(res.data.message || "会话隔离设置已更新");
this.sessionSettingLoading = false;
}).catch((err) => {
this.sessionSeparationEnabled = !this.sessionSeparationEnabled; //
this.showError(err.response?.data?.message || err.message);
this.sessionSettingLoading = false;
});
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
@@ -23,6 +23,8 @@ async function validate(values: any, { setErrors }: any) {
}
const authStore = useAuthStore();
// @ts-ignore
authStore.returnUrl = new URLSearchParams(window.location.search).get('redirect');
return authStore.login(username.value, password_).then((res) => {
console.log(res);
loading.value = false;
+55 -44
View File
@@ -12,6 +12,7 @@ from astrbot.api import sp
from astrbot.api.provider import ProviderRequest
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.sources.dify_source import ProviderDify
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -139,6 +140,7 @@ class Main(star.Star):
{notice}"""
event.set_result(MessageEventResult().message(msg).use_t2i(False))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("llm")
async def llm(self, event: AstrMessageEvent):
@@ -413,20 +415,21 @@ UID: {user_id} 此 ID 可用于设置管理员。
event.set_result(MessageEventResult().message("删除白名单成功。"))
except ValueError:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("provider")
async def provider(
self, event: AstrMessageEvent, idx: Union[str, int] = None, idx2: int = None
):
"""查看或者切换 LLM Provider"""
umo = event.unified_msg_origin
if idx is None:
ret = "## 载入的 LLM 提供商\n"
for idx, llm in enumerate(self.context.get_all_providers()):
id_ = llm.meta().id
ret += f"{idx + 1}. {id_} ({llm.meta().model})"
provider_using = self.context.get_using_provider()
provider_using = self.context.get_using_provider(umo=umo)
if provider_using and provider_using.meta().id == id_:
ret += " (当前使用)"
ret += "\n"
@@ -437,7 +440,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
for idx, tts in enumerate(tts_providers):
id_ = tts.meta().id
ret += f"{idx + 1}. {id_}"
tts_using = self.context.get_using_tts_provider()
tts_using = self.context.get_using_tts_provider(umo=umo)
if tts_using and tts_using.meta().id == id_:
ret += " (当前使用)"
ret += "\n"
@@ -448,7 +451,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
for idx, stt in enumerate(stt_providers):
id_ = stt.meta().id
ret += f"{idx + 1}. {id_}"
stt_using = self.context.get_using_stt_provider()
stt_using = self.context.get_using_stt_provider(umo=umo)
if stt_using and stt_using.meta().id == id_:
ret += " (当前使用)"
ret += "\n"
@@ -461,46 +464,54 @@ UID: {user_id} 此 ID 可用于设置管理员。
ret += "\n使用 /provider stt <切换> STT 提供商。"
event.set_result(MessageEventResult().message(ret))
else:
if idx == "tts":
if idx2 is None:
event.set_result(MessageEventResult().message("请输入序号。"))
return
else:
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
provider = self.context.get_all_tts_providers()[idx2 - 1]
id_ = provider.meta().id
self.context.provider_manager.curr_tts_provider_inst = provider
sp.put("curr_provider_tts", id_)
event.set_result(
MessageEventResult().message(f"成功切换到 {id_}")
)
elif idx == "stt":
if idx2 is None:
event.set_result(MessageEventResult().message("请输入序号。"))
return
else:
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
provider = self.context.get_all_stt_providers()[idx2 - 1]
id_ = provider.meta().id
self.context.provider_manager.curr_stt_provider_inst = provider
sp.put("curr_provider_stt", id_)
event.set_result(
MessageEventResult().message(f"成功切换到 {id_}")
)
elif isinstance(idx, int):
if idx > len(self.context.get_all_providers()) or idx < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
provider = self.context.get_all_providers()[idx - 1]
id_ = provider.meta().id
self.context.provider_manager.curr_provider_inst = provider
sp.put("curr_provider", id_)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
elif idx == "tts":
if idx2 is None:
event.set_result(MessageEventResult().message("请输入序号。"))
return
else:
event.set_result(MessageEventResult().message("无效的参数。"))
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
provider = self.context.get_all_tts_providers()[idx2 - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.TEXT_TO_SPEECH,
umo=umo,
)
event.set_result(
MessageEventResult().message(f"成功切换到 {id_}")
)
elif idx == "stt":
if idx2 is None:
event.set_result(MessageEventResult().message("请输入序号。"))
return
else:
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
provider = self.context.get_all_stt_providers()[idx2 - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.SPEECH_TO_TEXT,
umo=umo,
)
event.set_result(
MessageEventResult().message(f"成功切换到 {id_}")
)
elif isinstance(idx, int):
if idx > len(self.context.get_all_providers()) or idx < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
provider = self.context.get_all_providers()[idx - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
else:
event.set_result(MessageEventResult().message("无效的参数。"))
@filter.command("reset")
async def reset(self, message: AstrMessageEvent):
@@ -572,7 +583,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
ret += f"\n聊天增强: 已清除 {cnt} 条聊天记录。"
message.set_result(MessageEventResult().message(ret))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("model")
async def model_ls(
+36 -33
View File
@@ -26,9 +26,7 @@ class Waiter(Star):
def __init__(self, context: Context):
super().__init__(context)
self.empty_mention_waiting = self.context.get_config()["platform_settings"][
"empty_mention_waiting"
]
self.p_settings: dict = self.context.get_config()["platform_settings"]
self.wake_prefix = self.context.get_config()["wake_prefix"]
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
@@ -49,44 +47,49 @@ class Waiter(Star):
if (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and self.empty_mention_waiting
and self.p_settings.get("empty_mention_waiting", True)
) or (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in self.wake_prefix
):
try:
# 尝试使用 LLM 生成更生动的回复
func_tools_mgr = self.context.get_llm_tool_manager()
if self.p_settings.get("empty_mention_waiting_need_reply", True):
try:
# 尝试使用 LLM 生成更生动的回复
func_tools_mgr = self.context.get_llm_tool_manager()
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin
)
conversation = None
if curr_cid:
conversation = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin, curr_cid
)
else:
# 创建新对话
curr_cid = await self.context.conversation_manager.new_conversation(
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin
)
conversation = None
# 使用 LLM 生成回复
yield event.request_llm(
prompt="注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。请你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。注意,你仅需要输出要回复用户的内容,不要输出其他任何东西",
func_tool_manager=func_tools_mgr,
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {str(e)}")
# LLM 回复失败,使用原始预设回复
yield event.plain_result("想要问什么呢?😄")
if curr_cid:
conversation = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin, curr_cid
)
else:
# 创建新对话
curr_cid = await self.context.conversation_manager.new_conversation(
event.unified_msg_origin
)
# 使用 LLM 生成回复
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
func_tool_manager=func_tools_mgr,
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {str(e)}")
# LLM 回复失败,使用原始预设回复
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "3.5.14"
version = "3.5.15"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"
Generated
+2 -2
View File
@@ -204,7 +204,7 @@ wheels = [
[[package]]
name = "astrbot"
version = "3.5.12"
version = "3.5.14"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -266,7 +266,7 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "dingtalk-stream", specifier = ">=0.22.1" },
{ name = "docstring-parser", specifier = ">=0.16" },
{ name = "faiss-cpu", specifier = ">=1.11.0" },
{ name = "faiss-cpu", specifier = ">=1.10.0" },
{ name = "filelock", specifier = ">=3.18.0" },
{ name = "google-genai", specifier = ">=1.14.0" },
{ name = "googlesearch-python", specifier = ">=1.3.0" },