Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90602dd97f | |||
| 1ab2fa1788 | |||
| 650a092cc1 | |||
| 6240125440 | |||
| aff92a48bf | |||
| d0998a9dfb | |||
| 3678688433 | |||
| 0c03177840 | |||
| 20ff719c00 | |||
| 8a8ec492d7 | |||
| 02c1443dd1 | |||
| 79301f192c | |||
| 4b2c854c42 | |||
| d02ee7be8b | |||
| dbeadb6833 | |||
| 478cc32de1 | |||
| 7b302445c2 | |||
| ae839ef6d8 | |||
| 144a53f4b3 | |||
| fa1d1e6034 | |||
| a404436f2c | |||
| bcb12a0717 | |||
| 5d0fc8ac7a | |||
| a4d37e2c20 | |||
| c599fb75ed |
@@ -17,7 +17,6 @@ ENV/
|
||||
.conda/
|
||||
dashboard/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
|
||||
@@ -81,9 +81,15 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### 桌面应用部署(Tauri)
|
||||
|
||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||
|
||||
#### 启动器一键部署(AstrBot Launcher)
|
||||
|
||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
#### 宝塔面板部署
|
||||
|
||||
@@ -146,10 +152,6 @@ yay -S astrbot-git
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### 桌面端(Tauri)
|
||||
|
||||
桌面端已迁移为独立仓库(Tauri):[https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
**官方维护**
|
||||
@@ -262,7 +264,7 @@ pre-commit install
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||
|
||||
+1
-1
@@ -268,7 +268,7 @@ pre-commit install
|
||||
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
|
||||
+1
-1
@@ -262,7 +262,7 @@ pre-commit install
|
||||
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||
|
||||
+1
-1
@@ -263,7 +263,7 @@ pre-commit install
|
||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
|
||||
+1
-1
@@ -253,7 +253,7 @@ pre-commit install
|
||||
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
||||
|
||||
+1
-1
@@ -253,7 +253,7 @@ pre-commit install
|
||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
|
||||
@@ -102,6 +102,30 @@ class ConversationCommands:
|
||||
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话正在运行的 Agent"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
umo = message.unified_msg_origin
|
||||
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
stopped_count = active_event_registry.stop_all(umo, exclude=message)
|
||||
else:
|
||||
stopped_count = active_event_registry.request_agent_stop_all(
|
||||
umo,
|
||||
exclude=message,
|
||||
)
|
||||
|
||||
if stopped_count > 0:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"已请求停止 {stopped_count} 个运行中的任务。"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
|
||||
|
||||
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话记录"""
|
||||
if not self.context.get_using_provider(message.unified_msg_origin):
|
||||
|
||||
@@ -132,6 +132,11 @@ class Main(star.Star):
|
||||
"""重置 LLM 会话"""
|
||||
await self.conversation_c.reset(message)
|
||||
|
||||
@filter.command("stop")
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话中正在运行的 Agent"""
|
||||
await self.conversation_c.stop(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("model")
|
||||
async def model_ls(
|
||||
|
||||
@@ -70,7 +70,7 @@ class Main(star.Star):
|
||||
header = HEADERS
|
||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, headers=header, timeout=6) as response:
|
||||
async with session.get(url, headers=header) as response:
|
||||
html = await response.text(encoding="utf-8")
|
||||
doc = Document(html)
|
||||
ret = doc.summary(html_partial=True)
|
||||
@@ -151,7 +151,6 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -183,7 +182,6 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -265,7 +263,7 @@ class Main(star.Star):
|
||||
"transport": "sse",
|
||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||
"headers": {},
|
||||
"timeout": 30,
|
||||
"timeout": 600,
|
||||
},
|
||||
)
|
||||
self.baidu_initialized = True
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.17.6"
|
||||
__version__ = "4.18.1"
|
||||
|
||||
@@ -44,6 +44,14 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
"type": "string",
|
||||
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
|
||||
},
|
||||
"background_task": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Defaults to false. "
|
||||
"Set to true if the task may take noticeable time, involves external tools, or the user does not need to wait. "
|
||||
"Use false only for quick, immediate tasks."
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.tool_executor = tool_executor
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
self._stop_requested = False
|
||||
self._aborted = False
|
||||
|
||||
# These two are used for tool schema mode handling
|
||||
# We now have two modes:
|
||||
@@ -328,6 +330,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
),
|
||||
)
|
||||
if self._stop_requested:
|
||||
llm_resp_result = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||
reasoning_content=llm_response.reasoning_content,
|
||||
reasoning_signature=llm_response.reasoning_signature,
|
||||
)
|
||||
break
|
||||
continue
|
||||
llm_resp_result = llm_response
|
||||
|
||||
@@ -339,6 +349,48 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
break # got final response
|
||||
|
||||
if not llm_resp_result:
|
||||
if self._stop_requested:
|
||||
llm_resp_result = LLMResponse(role="assistant", completion_text="")
|
||||
else:
|
||||
return
|
||||
|
||||
if self._stop_requested:
|
||||
logger.info("Agent execution was requested to stop by user.")
|
||||
llm_resp = llm_resp_result
|
||||
if llm_resp.role != "assistant":
|
||||
llm_resp = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||
)
|
||||
self.final_llm_resp = llm_resp
|
||||
self._aborted = True
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
if llm_resp.completion_text:
|
||||
parts.append(TextPart(text=llm_resp.completion_text))
|
||||
if parts:
|
||||
self.run_context.messages.append(
|
||||
Message(role="assistant", content=parts)
|
||||
)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
yield AgentResponse(
|
||||
type="aborted",
|
||||
data=AgentResponseData(chain=MessageChain(type="aborted")),
|
||||
)
|
||||
return
|
||||
|
||||
# 处理 LLM 响应
|
||||
@@ -848,5 +900,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""检查 Agent 是否已完成工作"""
|
||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||
|
||||
def request_stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
|
||||
def was_aborted(self) -> bool:
|
||||
return self._aborted
|
||||
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
return self.final_llm_resp
|
||||
|
||||
@@ -20,6 +20,10 @@ from astrbot.core.provider.provider import TTSProvider
|
||||
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
|
||||
|
||||
|
||||
def _should_stop_agent(astr_event) -> bool:
|
||||
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_runner: AgentRunner,
|
||||
max_step: int = 30,
|
||||
@@ -48,10 +52,28 @@ async def run_agent(
|
||||
)
|
||||
)
|
||||
|
||||
stop_watcher = asyncio.create_task(
|
||||
_watch_agent_stop_signal(agent_runner, astr_event),
|
||||
)
|
||||
try:
|
||||
async for resp in agent_runner.step():
|
||||
if astr_event.is_stopped():
|
||||
if _should_stop_agent(astr_event):
|
||||
agent_runner.request_stop()
|
||||
|
||||
if resp.type == "aborted":
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
astr_event.set_extra("agent_user_aborted", True)
|
||||
astr_event.set_extra("agent_stop_requested", False)
|
||||
return
|
||||
|
||||
if _should_stop_agent(astr_event):
|
||||
continue
|
||||
|
||||
if resp.type == "tool_call_result":
|
||||
msg_chain = resp.data["chain"]
|
||||
|
||||
@@ -120,6 +142,12 @@ async def run_agent(
|
||||
# display the reasoning content only when configured
|
||||
continue
|
||||
yield resp.data["chain"] # MessageChain
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if agent_runner.done():
|
||||
# send agent stats to webchat
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
@@ -133,6 +161,12 @@ async def run_agent(
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if "stop_watcher" in locals() and not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
||||
@@ -155,6 +189,14 @@ async def run_agent(
|
||||
return
|
||||
|
||||
|
||||
async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> None:
|
||||
while not agent_runner.done():
|
||||
if _should_stop_agent(astr_event):
|
||||
agent_runner.request_stop()
|
||||
return
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
async def run_live_agent(
|
||||
agent_runner: AgentRunner,
|
||||
tts_provider: TTSProvider | None = None,
|
||||
|
||||
@@ -45,6 +45,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
|
||||
"""
|
||||
if isinstance(tool, HandoffTool):
|
||||
is_bg = tool_args.pop("background_task", False)
|
||||
if is_bg:
|
||||
async for r in cls._execute_handoff_background(
|
||||
tool, run_context, **tool_args
|
||||
):
|
||||
yield r
|
||||
return
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
yield r
|
||||
return
|
||||
@@ -146,6 +153,86 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _execute_handoff_background(
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
**tool_args,
|
||||
):
|
||||
"""Execute a handoff as a background task.
|
||||
|
||||
Immediately yields a success response with a task_id, then runs
|
||||
the subagent asynchronously. When the subagent finishes, a
|
||||
``CronMessageEvent`` is created so the main LLM can inform the
|
||||
user of the result – the same pattern used by
|
||||
``_execute_background`` for regular background tasks.
|
||||
"""
|
||||
task_id = uuid.uuid4().hex
|
||||
|
||||
async def _run_handoff_in_background() -> None:
|
||||
try:
|
||||
await cls._do_handoff_background(
|
||||
tool=tool,
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
**tool_args,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(
|
||||
f"Background handoff {task_id} ({tool.name}) failed: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_run_handoff_in_background())
|
||||
|
||||
text_content = mcp.types.TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. "
|
||||
f"The subagent '{tool.agent.name}' is working on the task on hehalf you. "
|
||||
f"You will be notified when it finishes."
|
||||
),
|
||||
)
|
||||
yield mcp.types.CallToolResult(content=[text_content])
|
||||
|
||||
@classmethod
|
||||
async def _do_handoff_background(
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
task_id: str,
|
||||
**tool_args,
|
||||
) -> None:
|
||||
"""Run the subagent handoff and, on completion, wake the main agent."""
|
||||
result_text = ""
|
||||
try:
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
if isinstance(r, mcp.types.CallToolResult):
|
||||
for content in r.content:
|
||||
if isinstance(content, mcp.types.TextContent):
|
||||
result_text += content.text + "\n"
|
||||
except Exception as e:
|
||||
result_text = (
|
||||
f"error: Background task execution failed, internal error: {e!s}"
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
|
||||
await cls._wake_main_agent_for_background_result(
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
tool_name=tool.name,
|
||||
result_text=result_text,
|
||||
tool_args=tool_args,
|
||||
note=(
|
||||
event.get_extra("background_note")
|
||||
or f"Background task for subagent '{tool.agent.name}' finished."
|
||||
),
|
||||
summary_name=f"Dedicated to subagent `{tool.agent.name}`",
|
||||
extra_result_fields={"subagent_name": tool.agent.name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _execute_background(
|
||||
cls,
|
||||
@@ -154,12 +241,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
task_id: str,
|
||||
**tool_args,
|
||||
) -> None:
|
||||
from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildConfig,
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
|
||||
# run the tool
|
||||
result_text = ""
|
||||
try:
|
||||
@@ -177,21 +258,53 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
f"error: Background task execution failed, internal error: {e!s}"
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
|
||||
await cls._wake_main_agent_for_background_result(
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
tool_name=tool.name,
|
||||
result_text=result_text,
|
||||
tool_args=tool_args,
|
||||
note=(
|
||||
event.get_extra("background_note")
|
||||
or f"Background task {tool.name} finished."
|
||||
),
|
||||
summary_name=tool.name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _wake_main_agent_for_background_result(
|
||||
cls,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
*,
|
||||
task_id: str,
|
||||
tool_name: str,
|
||||
result_text: str,
|
||||
tool_args: dict[str, T.Any],
|
||||
note: str,
|
||||
summary_name: str,
|
||||
extra_result_fields: dict[str, T.Any] | None = None,
|
||||
) -> None:
|
||||
from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildConfig,
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
ctx = run_context.context.context
|
||||
|
||||
note = (
|
||||
event.get_extra("background_note")
|
||||
or f"Background task {tool.name} finished."
|
||||
)
|
||||
extras = {
|
||||
"background_task_result": {
|
||||
"task_id": task_id,
|
||||
"tool_name": tool.name,
|
||||
"result": result_text or "",
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
task_result = {
|
||||
"task_id": task_id,
|
||||
"tool_name": tool_name,
|
||||
"result": result_text or "",
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
if extra_result_fields:
|
||||
task_result.update(extra_result_fields)
|
||||
extras = {"background_task_result": task_result}
|
||||
|
||||
session = MessageSession.from_str(event.unified_msg_origin)
|
||||
cron_event = CronMessageEvent(
|
||||
context=ctx,
|
||||
@@ -222,8 +335,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
)
|
||||
req.prompt = (
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation."
|
||||
" After completing your task, summarize and output your actions and results."
|
||||
"Output using same language as previous conversation. "
|
||||
"If you need to deliver the result to the user immediately, "
|
||||
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
|
||||
"otherwise the user will not see the result. "
|
||||
"After completing your task, summarize and output your actions and results. "
|
||||
)
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
@@ -233,7 +349,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
event=cron_event, plugin_context=ctx, config=config, req=req
|
||||
)
|
||||
if not result:
|
||||
logger.error("Failed to build main agent for background task job.")
|
||||
logger.error(f"Failed to build main agent for background task {tool_name}.")
|
||||
return
|
||||
|
||||
runner = result.agent_runner
|
||||
@@ -243,7 +359,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
llm_resp = runner.get_final_llm_resp()
|
||||
task_meta = extras.get("background_task_result", {})
|
||||
summary_note = (
|
||||
f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} "
|
||||
f"[BackgroundTask] {summary_name} "
|
||||
f"(task_id={task_meta.get('task_id', task_id)}) finished. "
|
||||
f"Result: {task_meta.get('result') or result_text or 'no content'}"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.17.6"
|
||||
VERSION = "4.18.1"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -979,7 +979,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"anth_thinking_config": {"budget": 0},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"Moonshot": {
|
||||
"id": "moonshot",
|
||||
@@ -1042,6 +1042,18 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"OpenRouter": {
|
||||
"id": "openrouter",
|
||||
"provider": "openrouter",
|
||||
"type": "openrouter_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://openrouter.ai/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"NVIDIA": {
|
||||
"id": "nvidia",
|
||||
"provider": "nvidia",
|
||||
@@ -1952,13 +1964,25 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"anth_thinking_config": {
|
||||
"description": "Thinking Config",
|
||||
"description": "思考配置",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"type": {
|
||||
"description": "思考类型",
|
||||
"type": "string",
|
||||
"options": ["", "adaptive"],
|
||||
"hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking",
|
||||
},
|
||||
"budget": {
|
||||
"description": "Thinking Budget",
|
||||
"description": "思考预算",
|
||||
"type": "int",
|
||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
||||
"hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
||||
},
|
||||
"effort": {
|
||||
"description": "思考深度",
|
||||
"type": "string",
|
||||
"options": ["", "low", "medium", "high", "max"],
|
||||
"hint": "type 为 'adaptive' 时控制思考深度。默认 'high'。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ from deprecated import deprecated
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from astrbot.core.db.po import (
|
||||
ApiKey,
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
@@ -248,6 +249,55 @@ class BaseDatabase(abc.ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_api_key(
|
||||
self,
|
||||
name: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
scopes: list[str] | None,
|
||||
created_by: str,
|
||||
expires_at: datetime.datetime | None = None,
|
||||
) -> ApiKey:
|
||||
"""Create a new API key record."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def list_api_keys(self) -> list[ApiKey]:
|
||||
"""List all API keys."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||
"""Get an API key by key_id."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||
"""Get an active API key by hash (not revoked, not expired)."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def touch_api_key(self, key_id: str) -> None:
|
||||
"""Update last_used_at of an API key."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def revoke_api_key(self, key_id: str) -> bool:
|
||||
"""Revoke an API key.
|
||||
|
||||
Returns True when the key exists and is updated.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_api_key(self, key_id: str) -> bool:
|
||||
"""Delete an API key.
|
||||
|
||||
Returns True when the key exists and is deleted.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert_persona(
|
||||
self,
|
||||
@@ -608,6 +658,22 @@ class BaseDatabase(abc.ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator_paginated(
|
||||
self,
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
exclude_project_sessions: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated platform sessions and total count for a creator.
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], int]: (sessions_with_project_info, total_count)
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_platform_session(
|
||||
self,
|
||||
|
||||
@@ -288,6 +288,43 @@ class Attachment(TimestampMixin, SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class ApiKey(TimestampMixin, SQLModel, table=True):
|
||||
"""API keys used by external developers to access Open APIs."""
|
||||
|
||||
__tablename__: str = "api_keys"
|
||||
|
||||
inner_id: int | None = Field(
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
default=None,
|
||||
)
|
||||
key_id: str = Field(
|
||||
max_length=36,
|
||||
nullable=False,
|
||||
unique=True,
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
name: str = Field(max_length=255, nullable=False)
|
||||
key_hash: str = Field(max_length=128, nullable=False, unique=True)
|
||||
key_prefix: str = Field(max_length=24, nullable=False)
|
||||
scopes: list | None = Field(default=None, sa_type=JSON)
|
||||
created_by: str = Field(max_length=255, nullable=False)
|
||||
last_used_at: datetime | None = Field(default=None)
|
||||
expires_at: datetime | None = Field(default=None)
|
||||
revoked_at: datetime | None = Field(default=None)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"key_id",
|
||||
name="uix_api_key_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"key_hash",
|
||||
name="uix_api_key_hash",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
||||
"""This class represents projects for organizing ChatUI conversations.
|
||||
|
||||
|
||||
+180
-41
@@ -10,6 +10,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import (
|
||||
ApiKey,
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
@@ -573,6 +574,100 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
return result.rowcount
|
||||
|
||||
async def create_api_key(
|
||||
self,
|
||||
name: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
scopes: list[str] | None,
|
||||
created_by: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> ApiKey:
|
||||
"""Create a new API key record."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
api_key = ApiKey(
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=scopes,
|
||||
created_by=created_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(api_key)
|
||||
await session.flush()
|
||||
await session.refresh(api_key)
|
||||
return api_key
|
||||
|
||||
async def list_api_keys(self) -> list[ApiKey]:
|
||||
"""List all API keys."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ApiKey).order_by(desc(ApiKey.created_at))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||
"""Get an API key by key_id."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(ApiKey.key_id == key_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||
"""Get an active API key by hash (not revoked, not expired)."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
now = datetime.now(timezone.utc)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.key_hash == key_hash,
|
||||
col(ApiKey.revoked_at).is_(None),
|
||||
or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def touch_api_key(self, key_id: str) -> None:
|
||||
"""Update last_used_at of an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(last_used_at=datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
async def revoke_api_key(self, key_id: str) -> bool:
|
||||
"""Revoke an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
query = (
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(revoked_at=datetime.now(timezone.utc))
|
||||
)
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_api_key(self, key_id: str) -> bool:
|
||||
"""Delete an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
result = T.cast(
|
||||
CursorResult,
|
||||
await session.execute(
|
||||
delete(ApiKey).where(ApiKey.key_id == key_id)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def insert_persona(
|
||||
self,
|
||||
persona_id,
|
||||
@@ -1317,58 +1412,102 @@ class SQLiteDatabase(BaseDatabase):
|
||||
|
||||
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
||||
"""
|
||||
(
|
||||
sessions_with_projects,
|
||||
_,
|
||||
) = await self.get_platform_sessions_by_creator_paginated(
|
||||
creator=creator,
|
||||
platform_id=platform_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
exclude_project_sessions=False,
|
||||
)
|
||||
return sessions_with_projects
|
||||
|
||||
@staticmethod
|
||||
def _build_platform_sessions_query(
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
exclude_project_sessions: bool = False,
|
||||
):
|
||||
query = (
|
||||
select(
|
||||
PlatformSession,
|
||||
col(ChatUIProject.project_id),
|
||||
col(ChatUIProject.title).label("project_title"),
|
||||
col(ChatUIProject.emoji).label("project_emoji"),
|
||||
)
|
||||
.outerjoin(
|
||||
SessionProjectRelation,
|
||||
col(PlatformSession.session_id)
|
||||
== col(SessionProjectRelation.session_id),
|
||||
)
|
||||
.outerjoin(
|
||||
ChatUIProject,
|
||||
col(SessionProjectRelation.project_id) == col(ChatUIProject.project_id),
|
||||
)
|
||||
.where(col(PlatformSession.creator) == creator)
|
||||
)
|
||||
|
||||
if platform_id:
|
||||
query = query.where(PlatformSession.platform_id == platform_id)
|
||||
if exclude_project_sessions:
|
||||
query = query.where(col(ChatUIProject.project_id).is_(None))
|
||||
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]:
|
||||
sessions_with_projects = []
|
||||
for row in rows:
|
||||
platform_session = row[0]
|
||||
project_id = row[1]
|
||||
project_title = row[2]
|
||||
project_emoji = row[3]
|
||||
|
||||
session_dict = {
|
||||
"session": platform_session,
|
||||
"project_id": project_id,
|
||||
"project_title": project_title,
|
||||
"project_emoji": project_emoji,
|
||||
}
|
||||
sessions_with_projects.append(session_dict)
|
||||
|
||||
return sessions_with_projects
|
||||
|
||||
async def get_platform_sessions_by_creator_paginated(
|
||||
self,
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
exclude_project_sessions: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated Platform sessions for a creator with total count."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
|
||||
query = (
|
||||
select(
|
||||
PlatformSession,
|
||||
col(ChatUIProject.project_id),
|
||||
col(ChatUIProject.title).label("project_title"),
|
||||
col(ChatUIProject.emoji).label("project_emoji"),
|
||||
)
|
||||
.outerjoin(
|
||||
SessionProjectRelation,
|
||||
col(PlatformSession.session_id)
|
||||
== col(SessionProjectRelation.session_id),
|
||||
)
|
||||
.outerjoin(
|
||||
ChatUIProject,
|
||||
col(SessionProjectRelation.project_id)
|
||||
== col(ChatUIProject.project_id),
|
||||
)
|
||||
.where(col(PlatformSession.creator) == creator)
|
||||
base_query = self._build_platform_sessions_query(
|
||||
creator=creator,
|
||||
platform_id=platform_id,
|
||||
exclude_project_sessions=exclude_project_sessions,
|
||||
)
|
||||
|
||||
if platform_id:
|
||||
query = query.where(PlatformSession.platform_id == platform_id)
|
||||
total_result = await session.execute(
|
||||
select(func.count()).select_from(base_query.subquery())
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
query = (
|
||||
query.order_by(desc(PlatformSession.updated_at))
|
||||
result_query = (
|
||||
base_query.order_by(desc(PlatformSession.updated_at))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
result = await session.execute(result_query)
|
||||
|
||||
# Convert to list of dicts with session and project info
|
||||
sessions_with_projects = []
|
||||
for row in result.all():
|
||||
platform_session = row[0]
|
||||
project_id = row[1]
|
||||
project_title = row[2]
|
||||
project_emoji = row[3]
|
||||
|
||||
session_dict = {
|
||||
"session": platform_session,
|
||||
"project_id": project_id,
|
||||
"project_title": project_title,
|
||||
"project_emoji": project_emoji,
|
||||
}
|
||||
sessions_with_projects.append(session_dict)
|
||||
|
||||
return sessions_with_projects
|
||||
sessions_with_projects = self._rows_to_session_dicts(result.all())
|
||||
return sessions_with_projects, total
|
||||
|
||||
async def update_platform_session(
|
||||
self,
|
||||
|
||||
@@ -247,13 +247,16 @@ class InternalAgentSubStage(Stage):
|
||||
yield
|
||||
|
||||
# 保存历史记录
|
||||
if not event.is_stopped() and agent_runner.done():
|
||||
if agent_runner.done() and (
|
||||
not event.is_stopped() or agent_runner.was_aborted()
|
||||
):
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
agent_runner.get_final_llm_resp(),
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
elif streaming_response and not stream_to_general:
|
||||
@@ -308,13 +311,14 @@ class InternalAgentSubStage(Stage):
|
||||
)
|
||||
|
||||
# 检查事件是否被停止,如果被停止则不保存历史记录
|
||||
if not event.is_stopped():
|
||||
if not event.is_stopped() or agent_runner.was_aborted():
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
final_resp,
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
@@ -340,16 +344,29 @@ class InternalAgentSubStage(Stage):
|
||||
llm_response: LLMResponse | None,
|
||||
all_messages: list[Message],
|
||||
runner_stats: AgentStats | None,
|
||||
user_aborted: bool = False,
|
||||
) -> None:
|
||||
if (
|
||||
not req
|
||||
or not req.conversation
|
||||
or not llm_response
|
||||
or llm_response.role != "assistant"
|
||||
):
|
||||
if not req or not req.conversation:
|
||||
return
|
||||
|
||||
if not llm_response.completion_text and not req.tool_calls_result:
|
||||
if not llm_response and not user_aborted:
|
||||
return
|
||||
|
||||
if llm_response and llm_response.role != "assistant":
|
||||
if not user_aborted:
|
||||
return
|
||||
llm_response = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=llm_response.completion_text or "",
|
||||
)
|
||||
elif llm_response is None:
|
||||
llm_response = LLMResponse(role="assistant", completion_text="")
|
||||
|
||||
if (
|
||||
not llm_response.completion_text
|
||||
and not req.tool_calls_result
|
||||
and not user_aborted
|
||||
):
|
||||
logger.debug("LLM 响应为空,不保存记录。")
|
||||
return
|
||||
|
||||
@@ -363,6 +380,14 @@ class InternalAgentSubStage(Stage):
|
||||
continue
|
||||
message_to_save.append(message.model_dump())
|
||||
|
||||
# if user_aborted:
|
||||
# message_to_save.append(
|
||||
# Message(
|
||||
# role="assistant",
|
||||
# content="[User aborted this request. Partial output before abort was preserved.]",
|
||||
# ).model_dump()
|
||||
# )
|
||||
|
||||
token_usage = None
|
||||
if runner_stats:
|
||||
# token_usage = runner_stats.token_usage.total
|
||||
|
||||
@@ -25,6 +25,26 @@ from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
||||
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
|
||||
|
||||
|
||||
def _patch_qq_botpy_formdata() -> None:
|
||||
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
|
||||
|
||||
qq-botpy 1.2.1 defines botpy.http._FormData._gen_form_data() and expects
|
||||
aiohttp.FormData to have a private flag named _is_processed, which is no
|
||||
longer present in newer aiohttp versions.
|
||||
"""
|
||||
|
||||
try:
|
||||
from botpy.http import _FormData # type: ignore
|
||||
|
||||
if not hasattr(_FormData, "_is_processed"):
|
||||
setattr(_FormData, "_is_processed", False)
|
||||
except Exception:
|
||||
logger.debug("[QQOfficial] Skip botpy FormData patch.")
|
||||
|
||||
|
||||
_patch_qq_botpy_formdata()
|
||||
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||
|
||||
@@ -200,6 +220,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
case botpy.message.Message():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
# Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type.
|
||||
payload.pop("msg_type", None)
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_message(
|
||||
channel_id=source.channel_id,
|
||||
@@ -212,6 +234,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
case botpy.message.DirectMessage():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
# Guild DM send API (/dms/{guild_id}/messages) does not use v2 msg_type.
|
||||
payload.pop("msg_type", None)
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_dms(
|
||||
guild_id=source.guild_id,
|
||||
|
||||
@@ -174,14 +174,19 @@ class TelegramPlatformAdapter(Platform):
|
||||
if not handler_metadata.enabled:
|
||||
continue
|
||||
for event_filter in handler_metadata.event_filters:
|
||||
cmd_info = self._extract_command_info(
|
||||
cmd_info_list = self._extract_command_info(
|
||||
event_filter,
|
||||
handler_metadata,
|
||||
skip_commands,
|
||||
)
|
||||
if cmd_info:
|
||||
cmd_name, description = cmd_info
|
||||
command_dict.setdefault(cmd_name, description)
|
||||
if cmd_info_list:
|
||||
for cmd_name, description in cmd_info_list:
|
||||
if cmd_name in command_dict:
|
||||
logger.warning(
|
||||
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
|
||||
f"'{command_dict[cmd_name]}'"
|
||||
)
|
||||
command_dict.setdefault(cmd_name, description)
|
||||
|
||||
commands_a = sorted(command_dict.keys())
|
||||
return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a]
|
||||
@@ -191,9 +196,9 @@ class TelegramPlatformAdapter(Platform):
|
||||
event_filter,
|
||||
handler_metadata,
|
||||
skip_commands: set,
|
||||
) -> tuple[str, str] | None:
|
||||
"""从事件过滤器中提取指令信息"""
|
||||
cmd_name = None
|
||||
) -> list[tuple[str, str]] | None:
|
||||
"""从事件过滤器中提取指令信息,包括所有别名"""
|
||||
cmd_names = []
|
||||
is_group = False
|
||||
if isinstance(event_filter, CommandFilter) and event_filter.command_name:
|
||||
if (
|
||||
@@ -201,26 +206,32 @@ class TelegramPlatformAdapter(Platform):
|
||||
and event_filter.parent_command_names != [""]
|
||||
):
|
||||
return None
|
||||
cmd_name = event_filter.command_name
|
||||
# 收集主命令名和所有别名
|
||||
cmd_names = [event_filter.command_name]
|
||||
if event_filter.alias:
|
||||
cmd_names.extend(event_filter.alias)
|
||||
elif isinstance(event_filter, CommandGroupFilter):
|
||||
if event_filter.parent_group:
|
||||
return None
|
||||
cmd_name = event_filter.group_name
|
||||
cmd_names = [event_filter.group_name]
|
||||
is_group = True
|
||||
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
return None
|
||||
result = []
|
||||
for cmd_name in cmd_names:
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
continue
|
||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||
continue
|
||||
|
||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||
return None
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"Command group: {cmd_name}" if is_group else f"Command: {cmd_name}"
|
||||
)
|
||||
if len(description) > 30:
|
||||
description = description[:30] + "..."
|
||||
result.append((cmd_name, description))
|
||||
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}"
|
||||
)
|
||||
if len(description) > 30:
|
||||
description = description[:30] + "..."
|
||||
return cmd_name, description
|
||||
return result if result else None
|
||||
|
||||
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if not update.effective_chat:
|
||||
|
||||
@@ -11,13 +11,13 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .webchat_queue_mgr import webchat_queue_mgr
|
||||
|
||||
imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||
|
||||
|
||||
class WebChatMessageEvent(AstrMessageEvent):
|
||||
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
os.makedirs(imgs_dir, exist_ok=True)
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
async def _send(
|
||||
@@ -69,7 +69,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = f"{str(uuid.uuid4())}.jpg"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
image_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(image_base64))
|
||||
@@ -85,7 +85,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, Record):
|
||||
# save record to local
|
||||
filename = f"{str(uuid.uuid4())}.wav"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
record_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(record_base64))
|
||||
@@ -104,7 +104,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
original_name = comp.name or os.path.basename(file_path)
|
||||
ext = os.path.splitext(original_name)[1] or ""
|
||||
filename = f"{uuid.uuid4()!s}{ext}"
|
||||
dest_path = os.path.join(imgs_dir, filename)
|
||||
dest_path = os.path.join(attachments_dir, filename)
|
||||
shutil.copy2(file_path, dest_path)
|
||||
data = f"[FILE]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
|
||||
@@ -301,6 +301,10 @@ class ProviderManager:
|
||||
from .sources.oai_aihubmix_source import (
|
||||
ProviderAIHubMix as ProviderAIHubMix,
|
||||
)
|
||||
case "openrouter_chat_completion":
|
||||
from .sources.openrouter_source import (
|
||||
ProviderOpenRouter as ProviderOpenRouter,
|
||||
)
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
|
||||
@@ -33,20 +33,29 @@ class ProviderAnthropic(Provider):
|
||||
self,
|
||||
provider_config,
|
||||
provider_settings,
|
||||
*,
|
||||
use_api_key: bool = True,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
provider_config,
|
||||
provider_settings,
|
||||
)
|
||||
|
||||
self.chosen_api_key: str = ""
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
|
||||
self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
|
||||
if use_api_key:
|
||||
self._init_api_key(provider_config)
|
||||
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _init_api_key(self, provider_config: dict) -> None:
|
||||
self.chosen_api_key: str = ""
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
|
||||
self.client = AsyncAnthropic(
|
||||
api_key=self.chosen_api_key,
|
||||
timeout=self.timeout,
|
||||
@@ -54,15 +63,27 @@ class ProviderAnthropic(Provider):
|
||||
http_client=self._create_http_client(provider_config),
|
||||
)
|
||||
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
|
||||
"""创建带代理的 HTTP 客户端"""
|
||||
proxy = provider_config.get("proxy", "")
|
||||
return create_proxy_client("Anthropic", proxy)
|
||||
|
||||
def _apply_thinking_config(self, payloads: dict) -> None:
|
||||
thinking_type = self.thinking_config.get("type", "")
|
||||
if thinking_type == "adaptive":
|
||||
payloads["thinking"] = {"type": "adaptive"}
|
||||
effort = self.thinking_config.get("effort", "")
|
||||
output_cfg = dict(payloads.get("output_config", {}))
|
||||
if effort:
|
||||
output_cfg["effort"] = effort
|
||||
if output_cfg:
|
||||
payloads["output_config"] = output_cfg
|
||||
elif not thinking_type and self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
|
||||
def _prepare_payload(self, messages: list[dict]):
|
||||
"""准备 Anthropic API 的请求 payload
|
||||
|
||||
@@ -213,11 +234,7 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
if self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
try:
|
||||
completion = await self.client.messages.create(
|
||||
@@ -287,11 +304,7 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
if self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
async with self.client.messages.stream(
|
||||
**payloads, extra_body=extra_body
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from ..register import register_provider_adapter
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"openrouter_chat_completion", "OpenRouter Chat Completion Provider Adapter"
|
||||
)
|
||||
class ProviderOpenRouter(ProviderOpenAIOfficial):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
# Reference to: https://openrouter.ai/docs/api/reference/overview#headers
|
||||
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
||||
"https://github.com/AstrBotDevs/AstrBot"
|
||||
)
|
||||
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
|
||||
@@ -46,5 +46,22 @@ class ActiveEventRegistry:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def request_agent_stop_all(
|
||||
self,
|
||||
umo: str,
|
||||
exclude: AstrMessageEvent | None = None,
|
||||
) -> int:
|
||||
"""请求停止指定 UMO 的所有活跃事件中的 Agent 运行。
|
||||
|
||||
与 stop_all 不同,这里不会调用 event.stop_event(),
|
||||
因此不会中断事件传播,后续流程(如历史记录保存)仍可继续。
|
||||
"""
|
||||
count = 0
|
||||
for event in list(self._events.get(umo, [])):
|
||||
if event is not exclude:
|
||||
event.set_extra("agent_stop_requested", True)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
active_event_registry = ActiveEventRegistry()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .api_key import ApiKeyRoute
|
||||
from .auth import AuthRoute
|
||||
from .backup import BackupRoute
|
||||
from .chat import ChatRoute
|
||||
@@ -9,6 +10,7 @@ from .cron import CronRoute
|
||||
from .file import FileRoute
|
||||
from .knowledge_base import KnowledgeBaseRoute
|
||||
from .log import LogRoute
|
||||
from .open_api import OpenApiRoute
|
||||
from .persona import PersonaRoute
|
||||
from .platform import PlatformRoute
|
||||
from .plugin import PluginRoute
|
||||
@@ -21,6 +23,7 @@ from .tools import ToolsRoute
|
||||
from .update import UpdateRoute
|
||||
|
||||
__all__ = [
|
||||
"ApiKeyRoute",
|
||||
"AuthRoute",
|
||||
"BackupRoute",
|
||||
"ChatRoute",
|
||||
@@ -32,6 +35,7 @@ __all__ = [
|
||||
"FileRoute",
|
||||
"KnowledgeBaseRoute",
|
||||
"LogRoute",
|
||||
"OpenApiRoute",
|
||||
"PersonaRoute",
|
||||
"PlatformRoute",
|
||||
"PluginRoute",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from quart import g, request
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im")
|
||||
|
||||
|
||||
class ApiKeyRoute(Route):
|
||||
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.routes = {
|
||||
"/apikey/list": ("GET", self.list_api_keys),
|
||||
"/apikey/create": ("POST", self.create_api_key),
|
||||
"/apikey/revoke": ("POST", self.revoke_api_key),
|
||||
"/apikey/delete": ("POST", self.delete_api_key),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_utc(dt: datetime | None) -> datetime | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
@classmethod
|
||||
def _serialize_datetime(cls, dt: datetime | None) -> str | None:
|
||||
normalized = cls._normalize_utc(dt)
|
||||
if normalized is None:
|
||||
return None
|
||||
return normalized.astimezone().isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _hash_key(raw_key: str) -> str:
|
||||
return hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
raw_key.encode("utf-8"),
|
||||
b"astrbot_api_key",
|
||||
100_000,
|
||||
).hex()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_api_key(key) -> dict:
|
||||
expires_at = ApiKeyRoute._normalize_utc(key.expires_at)
|
||||
return {
|
||||
"key_id": key.key_id,
|
||||
"name": key.name,
|
||||
"key_prefix": key.key_prefix,
|
||||
"scopes": key.scopes or [],
|
||||
"created_by": key.created_by,
|
||||
"created_at": ApiKeyRoute._serialize_datetime(key.created_at),
|
||||
"updated_at": ApiKeyRoute._serialize_datetime(key.updated_at),
|
||||
"last_used_at": ApiKeyRoute._serialize_datetime(key.last_used_at),
|
||||
"expires_at": ApiKeyRoute._serialize_datetime(key.expires_at),
|
||||
"revoked_at": ApiKeyRoute._serialize_datetime(key.revoked_at),
|
||||
"is_revoked": key.revoked_at is not None,
|
||||
"is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
|
||||
}
|
||||
|
||||
async def list_api_keys(self):
|
||||
keys = await self.db.list_api_keys()
|
||||
return (
|
||||
Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__
|
||||
)
|
||||
|
||||
async def create_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
|
||||
name = str(post_data.get("name", "")).strip() or "Untitled API Key"
|
||||
scopes = post_data.get("scopes")
|
||||
if scopes is None:
|
||||
normalized_scopes = list(ALL_OPEN_API_SCOPES)
|
||||
elif isinstance(scopes, list):
|
||||
normalized_scopes = [
|
||||
scope
|
||||
for scope in scopes
|
||||
if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES
|
||||
]
|
||||
normalized_scopes = list(dict.fromkeys(normalized_scopes))
|
||||
if not normalized_scopes:
|
||||
return Response().error("At least one valid scope is required").__dict__
|
||||
else:
|
||||
return Response().error("Invalid scopes").__dict__
|
||||
|
||||
expires_at = None
|
||||
expires_in_days = post_data.get("expires_in_days")
|
||||
if expires_in_days is not None:
|
||||
try:
|
||||
expires_in_days_int = int(expires_in_days)
|
||||
except (TypeError, ValueError):
|
||||
return Response().error("expires_in_days must be an integer").__dict__
|
||||
if expires_in_days_int <= 0:
|
||||
return (
|
||||
Response().error("expires_in_days must be greater than 0").__dict__
|
||||
)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
days=expires_in_days_int
|
||||
)
|
||||
|
||||
raw_key = f"abk_{secrets.token_urlsafe(32)}"
|
||||
key_hash = self._hash_key(raw_key)
|
||||
key_prefix = raw_key[:12]
|
||||
created_by = g.get("username", "unknown")
|
||||
|
||||
api_key = await self.db.create_api_key(
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=normalized_scopes, # type: ignore
|
||||
created_by=created_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
payload = self._serialize_api_key(api_key)
|
||||
payload["api_key"] = raw_key
|
||||
return Response().ok(data=payload).__dict__
|
||||
|
||||
async def revoke_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
key_id = post_data.get("key_id")
|
||||
if not key_id:
|
||||
return Response().error("Missing key: key_id").__dict__
|
||||
|
||||
success = await self.db.revoke_api_key(key_id)
|
||||
if not success:
|
||||
return Response().error("API key not found").__dict__
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def delete_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
key_id = post_data.get("key_id")
|
||||
if not key_id:
|
||||
return Response().error("Missing key: key_id").__dict__
|
||||
|
||||
success = await self.db.delete_api_key(key_id)
|
||||
if not success:
|
||||
return Response().error("API key not found").__dict__
|
||||
return Response().ok().__dict__
|
||||
@@ -13,7 +13,9 @@ from quart import g, make_response, request, send_file
|
||||
from astrbot.core import logger, sp
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
@@ -41,6 +43,7 @@ class ChatRoute(Route):
|
||||
"/chat/new_session": ("GET", self.new_session),
|
||||
"/chat/sessions": ("GET", self.get_sessions),
|
||||
"/chat/get_session": ("GET", self.get_session),
|
||||
"/chat/stop": ("POST", self.stop_session),
|
||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||
"/chat/update_session_display_name": (
|
||||
"POST",
|
||||
@@ -52,8 +55,9 @@ class ChatRoute(Route):
|
||||
}
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.register_routes()
|
||||
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
||||
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||
self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
os.makedirs(self.attachments_dir, exist_ok=True)
|
||||
|
||||
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
|
||||
self.conv_mgr = core_lifecycle.conversation_manager
|
||||
@@ -69,9 +73,18 @@ class ChatRoute(Route):
|
||||
return Response().error("Missing key: filename").__dict__
|
||||
|
||||
try:
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_imgs_dir = os.path.realpath(self.imgs_dir)
|
||||
real_imgs_dir = os.path.realpath(self.attachments_dir)
|
||||
|
||||
if not os.path.exists(real_file_path):
|
||||
# try legacy
|
||||
file_path = os.path.join(
|
||||
self.legacy_img_dir, os.path.basename(filename)
|
||||
)
|
||||
if os.path.exists(file_path):
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_imgs_dir = os.path.realpath(self.legacy_img_dir)
|
||||
|
||||
if not real_file_path.startswith(real_imgs_dir):
|
||||
return Response().error("Invalid file path").__dict__
|
||||
@@ -125,7 +138,7 @@ class ChatRoute(Route):
|
||||
else:
|
||||
attach_type = "file"
|
||||
|
||||
path = os.path.join(self.imgs_dir, filename)
|
||||
path = os.path.join(self.attachments_dir, filename)
|
||||
await file.save(path)
|
||||
|
||||
# 创建 attachment 记录
|
||||
@@ -202,8 +215,13 @@ class ChatRoute(Route):
|
||||
filename: 存储的文件名
|
||||
attach_type: 附件类型 (image, record, file, video)
|
||||
"""
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
if not os.path.exists(file_path):
|
||||
basename = os.path.basename(filename)
|
||||
candidate_paths = [
|
||||
os.path.join(self.attachments_dir, basename),
|
||||
os.path.join(self.legacy_img_dir, basename),
|
||||
]
|
||||
file_path = next((p for p in candidate_paths if os.path.exists(p)), None)
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
# guess mime type
|
||||
@@ -317,10 +335,13 @@ class ChatRoute(Route):
|
||||
)
|
||||
return record
|
||||
|
||||
async def chat(self):
|
||||
async def chat(self, post_data: dict | None = None):
|
||||
username = g.get("username", "guest")
|
||||
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
if "message" not in post_data and "files" not in post_data:
|
||||
return Response().error("Missing key: message or files").__dict__
|
||||
|
||||
@@ -373,6 +394,14 @@ class ChatRoute(Route):
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
try:
|
||||
# Emit session_id first so clients can bind the stream immediately.
|
||||
session_info = {
|
||||
"type": "session_id",
|
||||
"data": None,
|
||||
"session_id": webchat_conv_id,
|
||||
}
|
||||
yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
|
||||
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
while True:
|
||||
try:
|
||||
@@ -445,13 +474,13 @@ class ChatRoute(Route):
|
||||
if tc_id in tool_calls:
|
||||
tool_calls[tc_id]["result"] = tcr.get("result")
|
||||
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
|
||||
accumulated_parts.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_calls": [tool_calls[tc_id]],
|
||||
}
|
||||
)
|
||||
tool_calls.pop(tc_id, None)
|
||||
accumulated_parts.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_calls": [tool_calls[tc_id]],
|
||||
}
|
||||
)
|
||||
tool_calls.pop(tc_id, None)
|
||||
elif chain_type == "reasoning":
|
||||
accumulated_reasoning += result_text
|
||||
elif streaming:
|
||||
@@ -582,6 +611,36 @@ class ChatRoute(Route):
|
||||
response.timeout = None # fix SSE auto disconnect issue
|
||||
return response
|
||||
|
||||
async def stop_session(self):
|
||||
"""Stop active agent runs for a session."""
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
|
||||
session_id = post_data.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
message_type = (
|
||||
MessageType.GROUP_MESSAGE.value
|
||||
if session.is_group
|
||||
else MessageType.FRIEND_MESSAGE.value
|
||||
)
|
||||
umo = (
|
||||
f"{session.platform_id}:{message_type}:"
|
||||
f"{session.platform_id}!{username}!{session_id}"
|
||||
)
|
||||
stopped_count = active_event_registry.request_agent_stop_all(umo)
|
||||
|
||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
@@ -705,23 +764,18 @@ class ChatRoute(Route):
|
||||
# 获取可选的 platform_id 参数
|
||||
platform_id = request.args.get("platform_id")
|
||||
|
||||
sessions = await self.db.get_platform_sessions_by_creator(
|
||||
sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
|
||||
creator=username,
|
||||
platform_id=platform_id,
|
||||
page=1,
|
||||
page_size=100, # 暂时返回前100个
|
||||
exclude_project_sessions=True,
|
||||
)
|
||||
|
||||
# 转换为字典格式,并添加项目信息
|
||||
# get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段
|
||||
# 转换为字典格式
|
||||
sessions_data = []
|
||||
for item in sessions:
|
||||
session = item["session"]
|
||||
project_id = item["project_id"]
|
||||
|
||||
# 跳过属于项目的会话(在侧边栏对话列表中不显示)
|
||||
if project_id is not None:
|
||||
continue
|
||||
|
||||
sessions_data.append(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from quart import g, request
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.message_session import MessageSesion
|
||||
|
||||
from .chat import ChatRoute
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
class OpenApiRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
context: RouteContext,
|
||||
db: BaseDatabase,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
chat_route: ChatRoute,
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.platform_manager = core_lifecycle.platform_manager
|
||||
self.chat_route = chat_route
|
||||
|
||||
self.routes = {
|
||||
"/v1/chat": ("POST", self.chat_send),
|
||||
"/v1/chat/sessions": ("GET", self.get_chat_sessions),
|
||||
"/v1/configs": ("GET", self.get_chat_configs),
|
||||
"/v1/file": ("POST", self.upload_file),
|
||||
"/v1/im/message": ("POST", self.send_message),
|
||||
"/v1/im/bots": ("GET", self.get_bots),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_open_username(
|
||||
raw_username: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if raw_username is None:
|
||||
return None, "Missing key: username"
|
||||
username = str(raw_username).strip()
|
||||
if not username:
|
||||
return None, "username is empty"
|
||||
return username, None
|
||||
|
||||
def _get_chat_config_list(self) -> list[dict]:
|
||||
conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
|
||||
|
||||
result = []
|
||||
for conf_info in conf_list:
|
||||
conf_id = str(conf_info.get("id", "")).strip()
|
||||
result.append(
|
||||
{
|
||||
"id": conf_id,
|
||||
"name": str(conf_info.get("name", "")).strip(),
|
||||
"path": str(conf_info.get("path", "")).strip(),
|
||||
"is_default": conf_id == "default",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]:
|
||||
raw_config_id = post_data.get("config_id")
|
||||
raw_config_name = post_data.get("config_name")
|
||||
config_id = str(raw_config_id).strip() if raw_config_id is not None else ""
|
||||
config_name = (
|
||||
str(raw_config_name).strip() if raw_config_name is not None else ""
|
||||
)
|
||||
|
||||
if not config_id and not config_name:
|
||||
return None, None
|
||||
|
||||
conf_list = self._get_chat_config_list()
|
||||
conf_map = {item["id"]: item for item in conf_list}
|
||||
|
||||
if config_id:
|
||||
if config_id not in conf_map:
|
||||
return None, f"config_id not found: {config_id}"
|
||||
return config_id, None
|
||||
|
||||
if not config_name:
|
||||
return None, "config_name is empty"
|
||||
|
||||
matched = [item for item in conf_list if item["name"] == config_name]
|
||||
if not matched:
|
||||
return None, f"config_name not found: {config_name}"
|
||||
if len(matched) > 1:
|
||||
return (
|
||||
None,
|
||||
f"config_name is ambiguous, please use config_id: {config_name}",
|
||||
)
|
||||
|
||||
return matched[0]["id"], None
|
||||
|
||||
async def _ensure_chat_session(
|
||||
self,
|
||||
username: str,
|
||||
session_id: str,
|
||||
) -> str | None:
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if session:
|
||||
if session.creator != username:
|
||||
return "session_id belongs to another username"
|
||||
return None
|
||||
|
||||
try:
|
||||
await self.db.create_platform_session(
|
||||
creator=username,
|
||||
platform_id="webchat",
|
||||
session_id=session_id,
|
||||
is_group=0,
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle rare race when same session_id is created concurrently.
|
||||
existing = await self.db.get_platform_session_by_id(session_id)
|
||||
if existing and existing.creator == username:
|
||||
return None
|
||||
logger.error("Failed to create chat session %s: %s", session_id, e)
|
||||
return f"Failed to create session: {e}"
|
||||
|
||||
return None
|
||||
|
||||
async def chat_send(self):
|
||||
post_data = await request.get_json(silent=True) or {}
|
||||
effective_username, username_err = self._resolve_open_username(
|
||||
post_data.get("username")
|
||||
)
|
||||
if username_err:
|
||||
return Response().error(username_err).__dict__
|
||||
if not effective_username:
|
||||
return Response().error("Invalid username").__dict__
|
||||
|
||||
raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
||||
session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
|
||||
if not session_id:
|
||||
session_id = str(uuid4())
|
||||
post_data["session_id"] = session_id
|
||||
ensure_session_err = await self._ensure_chat_session(
|
||||
effective_username,
|
||||
session_id,
|
||||
)
|
||||
if ensure_session_err:
|
||||
return Response().error(ensure_session_err).__dict__
|
||||
|
||||
config_id, resolve_err = self._resolve_chat_config_id(post_data)
|
||||
if resolve_err:
|
||||
return Response().error(resolve_err).__dict__
|
||||
|
||||
original_username = g.get("username", "guest")
|
||||
g.username = effective_username
|
||||
if config_id:
|
||||
umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}"
|
||||
try:
|
||||
if config_id == "default":
|
||||
await self.core_lifecycle.umop_config_router.delete_route(umo)
|
||||
else:
|
||||
await self.core_lifecycle.umop_config_router.update_route(
|
||||
umo, config_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update chat config route for %s with %s: %s",
|
||||
umo,
|
||||
config_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
Response()
|
||||
.error(f"Failed to update chat config route: {e}")
|
||||
.__dict__
|
||||
)
|
||||
try:
|
||||
return await self.chat_route.chat(post_data=post_data)
|
||||
finally:
|
||||
g.username = original_username
|
||||
|
||||
async def upload_file(self):
|
||||
return await self.chat_route.post_file()
|
||||
|
||||
async def get_chat_sessions(self):
|
||||
username, username_err = self._resolve_open_username(
|
||||
request.args.get("username")
|
||||
)
|
||||
if username_err:
|
||||
return Response().error(username_err).__dict__
|
||||
|
||||
assert username is not None # for type checker
|
||||
|
||||
try:
|
||||
page = int(request.args.get("page", 1))
|
||||
page_size = int(request.args.get("page_size", 20))
|
||||
except ValueError:
|
||||
return Response().error("page and page_size must be integers").__dict__
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1:
|
||||
page_size = 1
|
||||
if page_size > 100:
|
||||
page_size = 100
|
||||
|
||||
platform_id = request.args.get("platform_id")
|
||||
|
||||
(
|
||||
paginated_sessions,
|
||||
total,
|
||||
) = await self.db.get_platform_sessions_by_creator_paginated(
|
||||
creator=username,
|
||||
platform_id=platform_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
exclude_project_sessions=True,
|
||||
)
|
||||
|
||||
sessions_data = []
|
||||
for item in paginated_sessions:
|
||||
session = item["session"]
|
||||
sessions_data.append(
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"platform_id": session.platform_id,
|
||||
"creator": session.creator,
|
||||
"display_name": session.display_name,
|
||||
"is_group": session.is_group,
|
||||
"created_at": session.created_at.astimezone().isoformat(),
|
||||
"updated_at": session.updated_at.astimezone().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"sessions": sessions_data,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def get_chat_configs(self):
|
||||
conf_list = self._get_chat_config_list()
|
||||
return Response().ok(data={"configs": conf_list}).__dict__
|
||||
|
||||
async def _build_message_chain_from_payload(
|
||||
self,
|
||||
message_payload: str | list,
|
||||
) -> MessageChain:
|
||||
if isinstance(message_payload, str):
|
||||
text = message_payload.strip()
|
||||
if not text:
|
||||
raise ValueError("Message is empty")
|
||||
return MessageChain(chain=[Plain(text=text)])
|
||||
|
||||
if not isinstance(message_payload, list):
|
||||
raise ValueError("message must be a string or list")
|
||||
|
||||
components = []
|
||||
has_content = False
|
||||
|
||||
for part in message_payload:
|
||||
if not isinstance(part, dict):
|
||||
raise ValueError("message part must be an object")
|
||||
|
||||
part_type = str(part.get("type", "")).strip()
|
||||
if part_type == "plain":
|
||||
text = str(part.get("text", ""))
|
||||
if text:
|
||||
has_content = True
|
||||
components.append(Plain(text=text))
|
||||
continue
|
||||
|
||||
if part_type == "reply":
|
||||
message_id = part.get("message_id")
|
||||
if message_id is None:
|
||||
raise ValueError("reply part missing message_id")
|
||||
components.append(
|
||||
Reply(
|
||||
id=str(message_id),
|
||||
message_str=str(part.get("selected_text", "")),
|
||||
chain=[],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if part_type not in {"image", "record", "file", "video"}:
|
||||
raise ValueError(f"unsupported message part type: {part_type}")
|
||||
|
||||
has_content = True
|
||||
file_path: Path | None = None
|
||||
resolved_type = part_type
|
||||
filename = str(part.get("filename", "")).strip()
|
||||
|
||||
attachment_id = part.get("attachment_id")
|
||||
if attachment_id:
|
||||
attachment = await self.db.get_attachment_by_id(str(attachment_id))
|
||||
if not attachment:
|
||||
raise ValueError(f"attachment not found: {attachment_id}")
|
||||
file_path = Path(attachment.path)
|
||||
resolved_type = attachment.type
|
||||
if not filename:
|
||||
filename = file_path.name
|
||||
else:
|
||||
raise ValueError(f"{part_type} part missing attachment_id")
|
||||
|
||||
if not file_path.exists():
|
||||
raise ValueError(f"file not found: {file_path!s}")
|
||||
|
||||
file_path_str = str(file_path.resolve())
|
||||
if resolved_type == "image":
|
||||
components.append(Image.fromFileSystem(file_path_str))
|
||||
elif resolved_type == "record":
|
||||
components.append(Record.fromFileSystem(file_path_str))
|
||||
elif resolved_type == "video":
|
||||
components.append(Video.fromFileSystem(file_path_str))
|
||||
else:
|
||||
components.append(
|
||||
File(name=filename or file_path.name, file=file_path_str)
|
||||
)
|
||||
|
||||
if not components or not has_content:
|
||||
raise ValueError("Message content is empty (reply only is not allowed)")
|
||||
|
||||
return MessageChain(chain=components)
|
||||
|
||||
async def send_message(self):
|
||||
post_data = await request.json or {}
|
||||
message_payload = post_data.get("message", {})
|
||||
umo = post_data.get("umo")
|
||||
|
||||
if message_payload is None:
|
||||
return Response().error("Missing key: message").__dict__
|
||||
if not umo:
|
||||
return Response().error("Missing key: umo").__dict__
|
||||
|
||||
try:
|
||||
session = MessageSesion.from_str(str(umo))
|
||||
except Exception as e:
|
||||
return Response().error(f"Invalid umo: {e}").__dict__
|
||||
|
||||
platform_id = session.platform_name
|
||||
platform_inst = next(
|
||||
(
|
||||
inst
|
||||
for inst in self.platform_manager.platform_insts
|
||||
if inst.meta().id == platform_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not platform_inst:
|
||||
return (
|
||||
Response()
|
||||
.error(f"Bot not found or not running for platform: {platform_id}")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
try:
|
||||
message_chain = await self._build_message_chain_from_payload(
|
||||
message_payload
|
||||
)
|
||||
await platform_inst.send_by_session(session, message_chain)
|
||||
return Response().ok().__dict__
|
||||
except ValueError as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"Open API send_message failed: {e}", exc_info=True)
|
||||
return Response().error(f"Failed to send message: {e}").__dict__
|
||||
|
||||
async def get_bots(self):
|
||||
bot_ids = []
|
||||
for platform in self.core_lifecycle.astrbot_config.get("platform", []):
|
||||
platform_id = platform.get("id") if isinstance(platform, dict) else None
|
||||
if (
|
||||
isinstance(platform_id, str)
|
||||
and platform_id
|
||||
and platform_id not in bot_ids
|
||||
):
|
||||
bot_ids.append(platform_id)
|
||||
return Response().ok(data={"bot_ids": bot_ids}).__dict__
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
@@ -21,6 +22,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import get_local_ip_addresses
|
||||
|
||||
from .routes import *
|
||||
from .routes.api_key import ALL_OPEN_API_SCOPES
|
||||
from .routes.backup import BackupRoute
|
||||
from .routes.live_chat import LiveChatRoute
|
||||
from .routes.platform import PlatformRoute
|
||||
@@ -53,6 +55,7 @@ class AstrBotDashboard:
|
||||
) -> None:
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.config = core_lifecycle.astrbot_config
|
||||
self.db = db
|
||||
|
||||
# 参数指定webui目录
|
||||
if webui_dir and os.path.exists(webui_dir):
|
||||
@@ -88,7 +91,14 @@ class AstrBotDashboard:
|
||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.api_key_route = ApiKeyRoute(self.context, db)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.open_api_route = OpenApiRoute(
|
||||
self.context,
|
||||
db,
|
||||
core_lifecycle,
|
||||
self.chat_route,
|
||||
)
|
||||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
|
||||
@@ -130,6 +140,40 @@ class AstrBotDashboard:
|
||||
async def auth_middleware(self):
|
||||
if not request.path.startswith("/api"):
|
||||
return None
|
||||
if request.path.startswith("/api/v1"):
|
||||
raw_key = self._extract_raw_api_key()
|
||||
if not raw_key:
|
||||
r = jsonify(Response().error("Missing API key").__dict__)
|
||||
r.status_code = 401
|
||||
return r
|
||||
key_hash = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
raw_key.encode("utf-8"),
|
||||
b"astrbot_api_key",
|
||||
100_000,
|
||||
).hex()
|
||||
api_key = await self.db.get_active_api_key_by_hash(key_hash)
|
||||
if not api_key:
|
||||
r = jsonify(Response().error("Invalid API key").__dict__)
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
if isinstance(api_key.scopes, list):
|
||||
scopes = api_key.scopes
|
||||
else:
|
||||
scopes = list(ALL_OPEN_API_SCOPES)
|
||||
required_scope = self._get_required_open_api_scope(request.path)
|
||||
if required_scope and "*" not in scopes and required_scope not in scopes:
|
||||
r = jsonify(Response().error("Insufficient API key scope").__dict__)
|
||||
r.status_code = 403
|
||||
return r
|
||||
|
||||
g.api_key_id = api_key.key_id
|
||||
g.api_key_scopes = scopes
|
||||
g.username = f"api_key:{api_key.key_id}"
|
||||
await self.db.touch_api_key(api_key.key_id)
|
||||
return None
|
||||
|
||||
allowed_endpoints = [
|
||||
"/api/auth/login",
|
||||
"/api/file",
|
||||
@@ -158,6 +202,29 @@ class AstrBotDashboard:
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
@staticmethod
|
||||
def _extract_raw_api_key() -> str | None:
|
||||
if key := request.headers.get("X-API-Key"):
|
||||
return key.strip()
|
||||
auth_header = request.headers.get("Authorization", "").strip()
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header.removeprefix("Bearer ").strip()
|
||||
if auth_header.startswith("ApiKey "):
|
||||
return auth_header.removeprefix("ApiKey ").strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_required_open_api_scope(path: str) -> str | None:
|
||||
scope_map = {
|
||||
"/api/v1/chat": "chat",
|
||||
"/api/v1/chat/sessions": "chat",
|
||||
"/api/v1/configs": "config",
|
||||
"/api/v1/file": "file",
|
||||
"/api/v1/im/message": "im",
|
||||
"/api/v1/im/bots": "im",
|
||||
}
|
||||
return scope_map.get(path)
|
||||
|
||||
def check_port_in_use(self, port: int) -> bool:
|
||||
"""跨平台检测端口是否被占用"""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 AstrBot HTTP API,支持基于 API Key 的对话、会话查询、配置查询、文件上传与 IM 消息发送能力。详见[AstrBot HTTP API (Beta)](https://docs.astrbot.app/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280))。
|
||||
- 新增 Telegram 指令别名注册能力,别名可同步展示在 Telegram 指令菜单中 ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234))。
|
||||
- 新增 Anthropic 自适应思考参数配置(type/effort),增强思考策略可控性 ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209))。
|
||||
|
||||
### 修复
|
||||
- 修复 QQ 官方频道消息发送异常问题,提升消息下发稳定性 ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287))。
|
||||
- 修复 ChatUI 使用非 default 配置文件对话时仍然使用 default 配置的问题 ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292))。
|
||||
|
||||
### 优化
|
||||
- 优化插件市场卡片的平台支持展示,改进移动端可用性与交互体验 ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271))。
|
||||
- 重构 Dashboard 桌面运行时桥接字段,从 `isElectron` 统一迁移至 `isDesktop`,提升跨端语义一致性 ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269))。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added AstrBot HTTP API with API Key support for chat, session listing, config listing, file upload, and IM message sending. See [AstrBot HTTP API (Beta)](https://docs.astrbot.app/en/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280)).
|
||||
- Added Telegram command alias registration so aliases can also appear in the Telegram command menu ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234)).
|
||||
- Added Anthropic adaptive thinking parameters (`type`/`effort`) for more flexible reasoning strategy control ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209)).
|
||||
|
||||
### Fixes
|
||||
- Fixed QQ official guild message sending errors to improve delivery stability ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287)).
|
||||
- Fixed chat config binding failures caused by missing session IDs when creating new chats, and improved localStorage fault tolerance ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292)).
|
||||
|
||||
### Improvements
|
||||
- Improved plugin marketplace card display for platform compatibility, with better mobile accessibility and interaction ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271)).
|
||||
- Refactored desktop runtime bridge fields in the dashboard from `isElectron` to `isDesktop` for clearer cross-platform semantics ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269)).
|
||||
@@ -0,0 +1,17 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
- fix: 修复插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309))
|
||||
|
||||
### 新增
|
||||
- SubAgent 支持后台执行模式配置:当 `background: true` 时,子代理将在后台运行,主对话无需等待子代理完成即可继续进行。当子代理完成后,会收到通知。适用于长时间运行或用户不需要立即结果的任务。([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081))
|
||||
- 配置 Schema 新增密码渲染支持:`string` 与 `text` 类型可通过 `password: true`(或 `render_type: "password"`)在 WebUI 中按密码输入方式显示。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Fixes
|
||||
- fix: Fixed a bug where the plugin marketplace would show blank cards for plugins; corrected the layout of installed plugin cards for consistent sizing ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309))
|
||||
|
||||
### New Features
|
||||
- Added background execution mode support for sub-agents: when `background: true` is set, the sub-agent will run in the background, allowing the main conversation to continue without waiting for the sub-agent to finish. You will be notified when the sub-agent completes. This is suitable for long-running tasks or when the user does not need immediate results. ([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081))
|
||||
- Added password rendering support in config schema: `string` and `text` fields can be rendered as password inputs in WebUI with `password: true` (or `render_type: "password"`).
|
||||
@@ -18,7 +18,6 @@ import { RouterView } from 'vue-router';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'
|
||||
import { restartAstrBot } from '@/utils/restartAstrBot'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const globalWaitingRef = ref(null)
|
||||
@@ -33,12 +32,12 @@ const snackbarShow = computed({
|
||||
|
||||
onMounted(() => {
|
||||
const desktopBridge = window.astrbotDesktop
|
||||
if (!desktopBridge?.isElectron || !desktopBridge.onTrayRestartBackend) {
|
||||
if (!desktopBridge?.onTrayRestartBackend) {
|
||||
return
|
||||
}
|
||||
disposeTrayRestartListener = desktopBridge.onTrayRestartBackend(async () => {
|
||||
try {
|
||||
await restartAstrBot(globalWaitingRef.value)
|
||||
await globalWaitingRef.value?.check?.()
|
||||
} catch (error) {
|
||||
globalWaitingRef.value?.stop?.()
|
||||
console.error('Tray restart backend failed:', error)
|
||||
|
||||
@@ -77,12 +77,14 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -106,12 +108,14 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -134,12 +138,14 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -298,6 +304,7 @@ const {
|
||||
currentSessionProject,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming
|
||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||
|
||||
@@ -631,6 +638,10 @@ async function handleSendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopMessage() {
|
||||
await stopMsg();
|
||||
}
|
||||
|
||||
// 路由变化监听
|
||||
watch(
|
||||
() => route.path,
|
||||
|
||||
@@ -94,8 +94,29 @@
|
||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple"
|
||||
:disabled="!canSend" class="send-btn" size="small" />
|
||||
<v-btn
|
||||
icon
|
||||
v-if="isRunning"
|
||||
@click="$emit('stop')"
|
||||
variant="text"
|
||||
class="send-btn"
|
||||
size="small"
|
||||
>
|
||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ tm('input.stopGenerating') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
@click="$emit('send')"
|
||||
icon="mdi-send"
|
||||
variant="text"
|
||||
color="deep-purple"
|
||||
:disabled="!canSend"
|
||||
class="send-btn"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,6 +181,7 @@ interface Props {
|
||||
disabled: boolean;
|
||||
enableStreaming: boolean;
|
||||
isRecording: boolean;
|
||||
isRunning: boolean;
|
||||
sessionId?: string | null;
|
||||
currentSession?: Session | null;
|
||||
configId?: string | null;
|
||||
@@ -177,6 +199,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<{
|
||||
'update:prompt': [value: string];
|
||||
send: [];
|
||||
stop: [];
|
||||
toggleStreaming: [];
|
||||
removeImage: [index: number];
|
||||
removeAudio: [];
|
||||
|
||||
@@ -77,6 +77,11 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import {
|
||||
getStoredDashboardUsername,
|
||||
getStoredSelectedChatConfigId,
|
||||
setStoredSelectedChatConfigId
|
||||
} from '@/utils/chatConfigBinding';
|
||||
|
||||
interface ConfigInfo {
|
||||
id: string;
|
||||
@@ -88,8 +93,6 @@ interface ConfigChangedPayload {
|
||||
agentRunnerType: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'chat.selectedConfigId';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sessionId?: string | null;
|
||||
platformId?: string;
|
||||
@@ -128,7 +131,7 @@ const hasActiveSession = computed(() => !!normalizedSessionId.value);
|
||||
|
||||
const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage'));
|
||||
|
||||
const username = computed(() => localStorage.getItem('user') || 'guest');
|
||||
const username = computed(() => getStoredDashboardUsername());
|
||||
|
||||
const sessionKey = computed(() => {
|
||||
if (!normalizedSessionId.value) {
|
||||
@@ -265,10 +268,10 @@ async function confirmSelection() {
|
||||
}
|
||||
const previousId = selectedConfigId.value;
|
||||
await setSelection(tempSelectedConfig.value);
|
||||
localStorage.setItem(STORAGE_KEY, tempSelectedConfig.value);
|
||||
setStoredSelectedChatConfigId(tempSelectedConfig.value);
|
||||
const applied = await applySelectionToBackend(tempSelectedConfig.value);
|
||||
if (!applied) {
|
||||
localStorage.setItem(STORAGE_KEY, previousId);
|
||||
setStoredSelectedChatConfigId(previousId);
|
||||
await setSelection(previousId);
|
||||
}
|
||||
dialog.value = false;
|
||||
@@ -287,7 +290,7 @@ async function syncSelectionForSession() {
|
||||
await fetchRoutingEntries();
|
||||
const resolved = resolveConfigId(targetUmo.value);
|
||||
await setSelection(resolved);
|
||||
localStorage.setItem(STORAGE_KEY, resolved);
|
||||
setStoredSelectedChatConfigId(resolved);
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -299,7 +302,7 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchConfigList();
|
||||
const stored = props.initialConfigId || localStorage.getItem(STORAGE_KEY) || 'default';
|
||||
const stored = props.initialConfigId || getStoredSelectedChatConfigId();
|
||||
selectedConfigId.value = stored;
|
||||
await setSelection(stored);
|
||||
await syncSelectionForSession();
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:config-id="configId"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -70,6 +72,7 @@ import { useMessages } from '@/composables/useMessages';
|
||||
import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
|
||||
|
||||
interface Props {
|
||||
configId?: string | null;
|
||||
@@ -82,6 +85,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const { t } = useI18n();
|
||||
const { error: showError } = useToast();
|
||||
|
||||
|
||||
// UI 状态
|
||||
const imagePreviewDialog = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
@@ -90,11 +94,33 @@ const previewImageUrl = ref('');
|
||||
const currSessionId = ref('');
|
||||
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
|
||||
|
||||
async function bindConfigToSession(sessionId: string) {
|
||||
const confId = (props.configId || '').trim();
|
||||
if (!confId || confId === 'default') {
|
||||
return;
|
||||
}
|
||||
|
||||
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||
|
||||
await axios.post('/api/config/umo_abconf_route/update', {
|
||||
umo: umoDetails.umo,
|
||||
conf_id: confId
|
||||
});
|
||||
}
|
||||
|
||||
async function newSession() {
|
||||
try {
|
||||
const response = await axios.get('/api/chat/new_session');
|
||||
const sessionId = response.data.data.session_id;
|
||||
|
||||
try {
|
||||
await bindConfigToSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error('Failed to bind config to session', err);
|
||||
}
|
||||
|
||||
currSessionId.value = sessionId;
|
||||
|
||||
return sessionId;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -132,6 +158,7 @@ const {
|
||||
enableStreaming,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming
|
||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||
|
||||
@@ -212,6 +239,10 @@ async function handleSendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopMessage() {
|
||||
await stopMsg();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 独立模式在挂载时创建新会话
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
||||
import PluginPlatformChip from "@/components/shared/PluginPlatformChip.vue";
|
||||
|
||||
const { tm } = useModuleI18n("features/extension");
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -26,11 +27,9 @@ const normalizePlatformList = (platforms) => {
|
||||
return platforms.filter((item) => typeof item === "string");
|
||||
};
|
||||
|
||||
const getPlatformDisplayList = (platforms) => {
|
||||
return normalizePlatformList(platforms).map((platformId) =>
|
||||
getPlatformDisplayName(platformId),
|
||||
);
|
||||
};
|
||||
const platformDisplayList = computed(() =>
|
||||
normalizePlatformList(props.plugin?.support_platforms),
|
||||
);
|
||||
|
||||
const handleInstall = (plugin) => {
|
||||
emit("install", plugin);
|
||||
@@ -165,9 +164,9 @@ const handleInstall = (plugin) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="plugin.astrbot_version || normalizePlatformList(plugin.support_platforms).length"
|
||||
v-if="plugin.astrbot_version || platformDisplayList.length"
|
||||
class="d-flex align-center flex-wrap"
|
||||
style="gap: 4px; margin-top: 4px; margin-bottom: 4px;"
|
||||
style="gap: 4px; margin-top: 4px; margin-bottom: 4px"
|
||||
>
|
||||
<v-chip
|
||||
v-if="plugin.astrbot_version"
|
||||
@@ -178,26 +177,11 @@ const handleInstall = (plugin) => {
|
||||
>
|
||||
AstrBot: {{ plugin.astrbot_version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="normalizePlatformList(plugin.support_platforms).length"
|
||||
<PluginPlatformChip
|
||||
:platforms="plugin.support_platforms"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
style="height: 20px"
|
||||
>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps">
|
||||
{{
|
||||
tm("card.status.supportPlatformsCount", {
|
||||
count: getPlatformDisplayList(plugin.support_platforms).length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ getPlatformDisplayList(plugin.support_platforms).join(", ") }}</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
:chip-style="{ height: '20px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center" style="gap: 8px; margin-top: auto">
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { ref, computed, inject } from "vue";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
||||
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
|
||||
import PluginPlatformChip from "./PluginPlatformChip.vue";
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -107,8 +108,9 @@ const viewChangelog = () => {
|
||||
|
||||
<template>
|
||||
<v-card
|
||||
class="mx-auto d-flex flex-column"
|
||||
class="mx-auto d-flex flex-column h-100"
|
||||
elevation="0"
|
||||
height="100%"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
backgroundColor:
|
||||
@@ -336,27 +338,10 @@ const viewChangelog = () => {
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="supportPlatforms.length"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
<PluginPlatformChip
|
||||
:platforms="supportPlatforms"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps">
|
||||
{{
|
||||
tm("card.status.supportPlatformsCount", {
|
||||
count: supportPlatformDisplayNames.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ supportPlatformDisplayNames.join(", ") }}</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
/>
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
|
||||
const props = defineProps({
|
||||
platforms: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "small",
|
||||
},
|
||||
chipStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n("features/extension");
|
||||
|
||||
const showMenu = ref(false);
|
||||
|
||||
const platformDetails = computed(() => {
|
||||
if (!Array.isArray(props.platforms)) return [];
|
||||
return props.platforms
|
||||
.filter((item) => typeof item === "string")
|
||||
.map((platformId) => ({
|
||||
name: getPlatformDisplayName(platformId as string),
|
||||
icon: getPlatformIcon(platformId as string),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-inline-block">
|
||||
<v-chip
|
||||
v-if="platformDetails.length"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
label
|
||||
:size="size"
|
||||
class="plugin-platform-chip"
|
||||
:style="{ cursor: 'pointer', ...chipStyle }"
|
||||
@click.stop="showMenu = !showMenu"
|
||||
>
|
||||
<div class="d-flex align-center" style="gap: 2px">
|
||||
<!-- 显示图标,最多 5 个 -->
|
||||
<div class="d-flex align-center mr-1" v-if="platformDetails.some(p => p.icon)">
|
||||
<v-avatar
|
||||
v-for="(platform, index) in platformDetails.slice(0, 5)"
|
||||
:key="index"
|
||||
:size="size === 'x-small' ? 12 : 14"
|
||||
class="platform-mini-icon"
|
||||
:style="{ marginLeft: index > 0 ? '-4px' : '0', zIndex: 10 - index }"
|
||||
>
|
||||
<v-img v-if="platform.icon" :src="platform.icon"></v-img>
|
||||
<v-icon v-else icon="mdi-circle-small" :size="size === 'x-small' ? 8 : 10"></v-icon>
|
||||
</v-avatar>
|
||||
</div>
|
||||
|
||||
<span class="text-caption font-weight-bold">
|
||||
{{
|
||||
tm("card.status.supportPlatformsCount", {
|
||||
count: platformDetails.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
|
||||
<v-icon
|
||||
:icon="showMenu ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
:size="size === 'x-small' ? 14 : 16"
|
||||
class="ml-n1"
|
||||
></v-icon>
|
||||
</div>
|
||||
|
||||
<v-menu
|
||||
v-model="showMenu"
|
||||
activator="parent"
|
||||
location="top"
|
||||
:close-on-content-click="false"
|
||||
transition="scale-transition"
|
||||
open-on-hover
|
||||
>
|
||||
<v-list density="compact" border elevation="12" class="rounded-lg pa-1">
|
||||
<v-list-item
|
||||
v-for="platform in platformDetails"
|
||||
:key="platform.name"
|
||||
min-height="24"
|
||||
class="px-2"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar size="14" class="mr-2" v-if="platform.icon">
|
||||
<v-img :src="platform.icon"></v-img>
|
||||
</v-avatar>
|
||||
<v-icon v-else icon="mdi-platform" size="12" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption font-weight-bold" style="font-size: 0.75rem !important">
|
||||
{{ platform.name }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-platform-chip {
|
||||
padding-left: 6px !important;
|
||||
padding-right: 4px !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.platform-mini-icon {
|
||||
border: 1px solid rgba(var(--v-theme-info), 0.3);
|
||||
background: rgba(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.plugin-platform-chip:hover {
|
||||
background: rgba(var(--v-theme-info), 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -82,6 +82,10 @@ export function useMessages(
|
||||
const activeSSECount = ref(0);
|
||||
const enableStreaming = ref(true);
|
||||
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
|
||||
const currentRequestController = ref<AbortController | null>(null);
|
||||
const currentReader = ref<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||||
const currentRunningSessionId = ref('');
|
||||
const userStopRequested = ref(false);
|
||||
|
||||
// 当前会话的项目信息
|
||||
const currentSessionProject = ref<{ project_id: string; title: string; emoji: string } | null>(null);
|
||||
@@ -289,6 +293,8 @@ export function useMessages(
|
||||
if (activeSSECount.value === 1) {
|
||||
isConvRunning.value = true;
|
||||
}
|
||||
userStopRequested.value = false;
|
||||
currentRunningSessionId.value = currSessionId.value;
|
||||
|
||||
// 收集所有 attachment_id
|
||||
const files = stagedFiles.map(f => f.attachment_id);
|
||||
@@ -330,12 +336,15 @@ export function useMessages(
|
||||
messageToSend = prompt;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
currentRequestController.value = controller;
|
||||
const response = await fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
message: messageToSend,
|
||||
session_id: currSessionId.value,
|
||||
@@ -350,6 +359,7 @@ export function useMessages(
|
||||
}
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
currentReader.value = reader;
|
||||
const decoder = new TextDecoder();
|
||||
let in_streaming = false;
|
||||
let message_obj: MessageContent | null = null;
|
||||
@@ -388,6 +398,10 @@ export function useMessages(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk_json.type === 'session_id') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastMsg = messages.value[messages.value.length - 1];
|
||||
if (lastMsg?.content?.isLoading) {
|
||||
messages.value.pop();
|
||||
@@ -556,7 +570,9 @@ export function useMessages(
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
console.error('SSE读取错误:', readError);
|
||||
if (!userStopRequested.value) {
|
||||
console.error('SSE读取错误:', readError);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -565,7 +581,9 @@ export function useMessages(
|
||||
onSessionsUpdate();
|
||||
|
||||
} catch (err) {
|
||||
console.error('发送消息失败:', err);
|
||||
if (!userStopRequested.value) {
|
||||
console.error('发送消息失败:', err);
|
||||
}
|
||||
// 移除加载占位符
|
||||
const lastMsg = messages.value[messages.value.length - 1];
|
||||
if (lastMsg?.content?.isLoading) {
|
||||
@@ -573,6 +591,10 @@ export function useMessages(
|
||||
}
|
||||
} finally {
|
||||
isStreaming.value = false;
|
||||
currentReader.value = null;
|
||||
currentRequestController.value = null;
|
||||
currentRunningSessionId.value = '';
|
||||
userStopRequested.value = false;
|
||||
activeSSECount.value--;
|
||||
if (activeSSECount.value === 0) {
|
||||
isConvRunning.value = false;
|
||||
@@ -580,6 +602,33 @@ export function useMessages(
|
||||
}
|
||||
}
|
||||
|
||||
async function stopMessage() {
|
||||
const sessionId = currentRunningSessionId.value || currSessionId.value;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
userStopRequested.value = true;
|
||||
try {
|
||||
await axios.post('/api/chat/stop', {
|
||||
session_id: sessionId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('停止会话失败:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await currentReader.value?.cancel();
|
||||
} catch (err) {
|
||||
// ignore reader cancel failures
|
||||
}
|
||||
currentReader.value = null;
|
||||
currentRequestController.value?.abort();
|
||||
currentRequestController.value = null;
|
||||
|
||||
isStreaming.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
isStreaming,
|
||||
@@ -588,6 +637,7 @@ export function useMessages(
|
||||
currentSessionProject,
|
||||
getSessionMessages,
|
||||
sendMessage,
|
||||
stopMessage,
|
||||
toggleStreaming,
|
||||
getAttachment
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { buildWebchatUmoDetails, getStoredSelectedChatConfigId } from '@/utils/chatConfigBinding';
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
@@ -62,10 +63,25 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
|
||||
async function newSession() {
|
||||
try {
|
||||
const selectedConfigId = getStoredSelectedChatConfigId();
|
||||
const response = await axios.get('/api/chat/new_session');
|
||||
const sessionId = response.data.data.session_id;
|
||||
const platformId = response.data.data.platform_id;
|
||||
|
||||
currSessionId.value = sessionId;
|
||||
|
||||
if (selectedConfigId && selectedConfigId !== 'default' && platformId === 'webchat') {
|
||||
try {
|
||||
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||
await axios.post('/api/config/umo_abconf_route/update', {
|
||||
umo: umoDetails.umo,
|
||||
conf_id: selectedConfigId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to bind config to session', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 URL
|
||||
const basePath = chatboxMode ? '/chatbox' : '/chat';
|
||||
router.push(`${basePath}/${sessionId}`);
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"voice": "Voice Input",
|
||||
"recordingPrompt": "Recording, please speak...",
|
||||
"chatPrompt": "Let's chat!",
|
||||
"dropToUpload": "Drop files to upload"
|
||||
"dropToUpload": "Drop files to upload",
|
||||
"stopGenerating": "Stop generating"
|
||||
},
|
||||
"message": {
|
||||
"user": "User",
|
||||
|
||||
@@ -1178,9 +1178,17 @@
|
||||
},
|
||||
"anth_thinking_config": {
|
||||
"description": "Thinking Config",
|
||||
"type": {
|
||||
"description": "Thinking Type",
|
||||
"hint": "Set 'adaptive' for Opus 4.6+ / Sonnet 4.6+ (recommended). Leave empty to use manual budget mode. See: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking"
|
||||
},
|
||||
"budget": {
|
||||
"description": "Thinking Budget",
|
||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. Only used when type is empty. Deprecated on Opus 4.6 / Sonnet 4.6. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||
},
|
||||
"effort": {
|
||||
"description": "Effort Level",
|
||||
"hint": "Controls thinking depth when type is 'adaptive'. 'high' is the default. 'max' is Opus 4.6 only. See: https://platform.claude.com/docs/en/build-with-claude/effort"
|
||||
}
|
||||
},
|
||||
"minimax-group-id": {
|
||||
|
||||
@@ -128,5 +128,53 @@
|
||||
"renameFailed": "Rename failed",
|
||||
"ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Keys",
|
||||
"manageTitle": "Developer Access Keys",
|
||||
"subtitle": "Create API keys for external developers to call open HTTP APIs.",
|
||||
"name": "Key Name",
|
||||
"expiresInDays": "Expiration",
|
||||
"expiryOptions": {
|
||||
"day1": "1 day",
|
||||
"day7": "7 days",
|
||||
"day30": "30 days",
|
||||
"day90": "90 days",
|
||||
"permanent": "Permanent"
|
||||
},
|
||||
"permanentWarning": "Permanent API keys are high risk. Store them securely and use only when necessary.",
|
||||
"scopes": "Scopes",
|
||||
"create": "Create API Key",
|
||||
"revoke": "Revoke",
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"docsLink": "Open docs",
|
||||
"plaintextHint": "Save this key now. The plaintext will not be shown again.",
|
||||
"empty": "No API keys",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"prefix": "Prefix",
|
||||
"scopes": "Scopes",
|
||||
"status": "Status",
|
||||
"lastUsed": "Last Used",
|
||||
"createdAt": "Created At",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Failed to load API keys",
|
||||
"scopeRequired": "Please select at least one scope",
|
||||
"createSuccess": "API key created",
|
||||
"createFailed": "Failed to create API key",
|
||||
"revokeSuccess": "API key revoked",
|
||||
"revokeFailed": "Failed to revoke API key",
|
||||
"deleteSuccess": "API key deleted",
|
||||
"deleteFailed": "Failed to delete API key",
|
||||
"copySuccess": "API key copied",
|
||||
"copyFailed": "Failed to copy API key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"voice": "语音输入",
|
||||
"recordingPrompt": "录音中,请说话...",
|
||||
"chatPrompt": "聊天吧!",
|
||||
"dropToUpload": "松开鼠标上传文件"
|
||||
"dropToUpload": "松开鼠标上传文件",
|
||||
"stopGenerating": "停止生成"
|
||||
},
|
||||
"message": {
|
||||
"user": "用户",
|
||||
|
||||
@@ -1181,9 +1181,17 @@
|
||||
},
|
||||
"anth_thinking_config": {
|
||||
"description": "思考配置",
|
||||
"type": {
|
||||
"description": "思考类型",
|
||||
"hint": "设为 'adaptive' 以使用自适应思考(推荐 Opus 4.6+ / Sonnet 4.6+)。留空则使用手动预算模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking"
|
||||
},
|
||||
"budget": {
|
||||
"description": "思考预算",
|
||||
"hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||
"hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。仅在思考类型为空时生效。Opus 4.6 / Sonnet 4.6 已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||
},
|
||||
"effort": {
|
||||
"description": "思考深度",
|
||||
"hint": "当思考类型为 'adaptive' 时控制思考深度。'high' 为默认值。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort"
|
||||
}
|
||||
},
|
||||
"minimax-group-id": {
|
||||
|
||||
@@ -128,5 +128,53 @@
|
||||
"renameFailed": "重命名失败",
|
||||
"ftpHint": "对于较大的备份文件,也可以通过 FTP/SFTP 等方式直接上传到 data/backups 目录"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key",
|
||||
"manageTitle": "开发者访问密钥",
|
||||
"subtitle": "为外部开发者创建 API Key,用于调用开放 HTTP API。",
|
||||
"name": "Key 名称",
|
||||
"expiresInDays": "有效期",
|
||||
"expiryOptions": {
|
||||
"day1": "1 天",
|
||||
"day7": "7 天",
|
||||
"day30": "30 天",
|
||||
"day90": "90 天",
|
||||
"permanent": "永久"
|
||||
},
|
||||
"permanentWarning": "永久有效的 API Key 风险较高,请妥善保存并建议仅在必要场景使用。",
|
||||
"scopes": "权限范围",
|
||||
"create": "创建 API Key",
|
||||
"revoke": "吊销",
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"docsLink": "查看文档",
|
||||
"plaintextHint": "请立即保存该 Key,关闭后将无法再次查看明文。",
|
||||
"empty": "暂无 API Key",
|
||||
"status": {
|
||||
"active": "有效",
|
||||
"inactive": "无效"
|
||||
},
|
||||
"table": {
|
||||
"name": "名称",
|
||||
"prefix": "前缀",
|
||||
"scopes": "权限",
|
||||
"status": "状态",
|
||||
"lastUsed": "最近使用",
|
||||
"createdAt": "创建时间",
|
||||
"actions": "操作"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "加载 API Key 失败",
|
||||
"scopeRequired": "请至少选择一个权限",
|
||||
"createSuccess": "API Key 创建成功",
|
||||
"createFailed": "创建 API Key 失败",
|
||||
"revokeSuccess": "API Key 已吊销",
|
||||
"revokeFailed": "吊销 API Key 失败",
|
||||
"deleteSuccess": "API Key 已删除",
|
||||
"deleteFailed": "删除 API Key 失败",
|
||||
"copySuccess": "已复制 API Key",
|
||||
"copyFailed": "复制 API Key 失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import { useLanguageSwitcher } from '@/i18n/composables';
|
||||
import type { Locale } from '@/i18n/types';
|
||||
import AboutPage from '@/views/AboutPage.vue';
|
||||
import { getDesktopRuntimeInfo } from '@/utils/desktopRuntime';
|
||||
|
||||
enableKatex();
|
||||
enableMermaid();
|
||||
@@ -46,8 +47,8 @@ let version = ref('');
|
||||
let releases = ref([]);
|
||||
let updatingDashboardLoading = ref(false);
|
||||
let installLoading = ref(false);
|
||||
const isElectronApp = ref(
|
||||
typeof window !== 'undefined' && !!window.astrbotDesktop?.isElectron
|
||||
const isDesktopReleaseMode = ref(
|
||||
typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop
|
||||
);
|
||||
const redirectConfirmDialog = ref(false);
|
||||
const pendingRedirectUrl = ref('');
|
||||
@@ -133,7 +134,7 @@ function confirmExternalRedirect() {
|
||||
}
|
||||
}
|
||||
|
||||
const getReleaseUrlForElectron = () => {
|
||||
const getReleaseUrlForDesktop = () => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.tag_name) {
|
||||
const tag = firstRelease.tag_name as string;
|
||||
@@ -147,12 +148,12 @@ const getReleaseUrlForElectron = () => {
|
||||
};
|
||||
|
||||
function handleUpdateClick() {
|
||||
if (isElectronApp.value) {
|
||||
if (isDesktopReleaseMode.value) {
|
||||
requestExternalRedirect('');
|
||||
resolvingReleaseTarget.value = true;
|
||||
checkUpdate();
|
||||
void getReleases().finally(() => {
|
||||
pendingRedirectUrl.value = getReleaseUrlForElectron() || fallbackReleaseUrl;
|
||||
pendingRedirectUrl.value = getReleaseUrlForDesktop() || fallbackReleaseUrl;
|
||||
resolvingReleaseTarget.value = false;
|
||||
});
|
||||
return;
|
||||
@@ -246,7 +247,7 @@ function checkUpdate() {
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
dashboardHasNewVersion.value = isElectronApp.value
|
||||
dashboardHasNewVersion.value = isDesktopReleaseMode.value
|
||||
? false
|
||||
: res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
@@ -388,13 +389,9 @@ const changeLanguage = async (langCode: string) => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isElectronApp.value = !!window.astrbotDesktop?.isElectron ||
|
||||
!!(await window.astrbotDesktop?.isElectronRuntime?.());
|
||||
} catch {
|
||||
isElectronApp.value = false;
|
||||
}
|
||||
if (isElectronApp.value) {
|
||||
const runtimeInfo = await getDesktopRuntimeInfo();
|
||||
isDesktopReleaseMode.value = runtimeInfo.isDesktopRuntime;
|
||||
if (isDesktopReleaseMode.value) {
|
||||
dashboardHasNewVersion.value = false;
|
||||
}
|
||||
});
|
||||
@@ -441,7 +438,7 @@ onMounted(async () => {
|
||||
<small v-if="hasNewVersion">
|
||||
{{ t('core.header.version.hasNewVersion') }}
|
||||
</small>
|
||||
<small v-else-if="dashboardHasNewVersion && !isElectronApp">
|
||||
<small v-else-if="dashboardHasNewVersion && !isDesktopReleaseMode">
|
||||
{{ t('core.header.version.dashboardHasNewVersion') }}
|
||||
</small>
|
||||
</div>
|
||||
@@ -524,7 +521,7 @@ onMounted(async () => {
|
||||
<v-icon>mdi-arrow-up-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.header.updateDialog.title') }}</v-list-item-title>
|
||||
<template v-slot:append v-if="hasNewVersion || (dashboardHasNewVersion && !isElectronApp)">
|
||||
<template v-slot:append v-if="hasNewVersion || (dashboardHasNewVersion && !isDesktopReleaseMode)">
|
||||
<v-chip size="x-small" color="primary" variant="tonal" class="ml-2">!</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ export {};
|
||||
declare global {
|
||||
interface Window {
|
||||
astrbotDesktop?: {
|
||||
isElectron: boolean;
|
||||
isElectronRuntime: () => Promise<boolean>;
|
||||
isDesktop: boolean;
|
||||
isDesktopRuntime: () => Promise<boolean>;
|
||||
getBackendState: () => Promise<{
|
||||
running: boolean;
|
||||
spawning: boolean;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export const CHAT_SELECTED_CONFIG_STORAGE_KEY = 'chat.selectedConfigId';
|
||||
|
||||
export type ChatMessageType = 'FriendMessage' | 'GroupMessage';
|
||||
|
||||
export interface WebchatUmoDetails {
|
||||
platformId: string;
|
||||
messageType: ChatMessageType;
|
||||
username: string;
|
||||
sessionKey: string;
|
||||
umo: string;
|
||||
}
|
||||
|
||||
function getFromLocalStorage(key: string, fallback: string): string {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
const value = localStorage.getItem(key);
|
||||
return value == null ? fallback : value;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function setToLocalStorage(key: string, value: string): void {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Ignore storage errors (e.g. private mode / restricted storage).
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredDashboardUsername(): string {
|
||||
return getFromLocalStorage('user', '').trim() || 'guest';
|
||||
}
|
||||
|
||||
export function getStoredSelectedChatConfigId(): string {
|
||||
return getFromLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, '').trim() || 'default';
|
||||
}
|
||||
|
||||
export function setStoredSelectedChatConfigId(configId: string): void {
|
||||
setToLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, configId);
|
||||
}
|
||||
|
||||
export function buildWebchatUmoDetails(sessionId: string, isGroup = false): WebchatUmoDetails {
|
||||
const platformId = 'webchat';
|
||||
const username = getStoredDashboardUsername();
|
||||
const messageType: ChatMessageType = isGroup ? 'GroupMessage' : 'FriendMessage';
|
||||
const sessionKey = `${platformId}!${username}!${sessionId}`;
|
||||
return {
|
||||
platformId,
|
||||
messageType,
|
||||
username,
|
||||
sessionKey,
|
||||
umo: `${platformId}:${messageType}:${sessionKey}`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type DesktopRuntimeInfo = {
|
||||
bridge: Window['astrbotDesktop'] | undefined
|
||||
hasDesktopRuntimeProbe: boolean
|
||||
hasDesktopRestartCapability: boolean
|
||||
isDesktopRuntime: boolean
|
||||
}
|
||||
|
||||
export async function getDesktopRuntimeInfo(): Promise<DesktopRuntimeInfo> {
|
||||
const bridge = window.astrbotDesktop
|
||||
const hasDesktopRuntimeProbe =
|
||||
!!bridge && typeof bridge.isDesktopRuntime === 'function'
|
||||
const hasDesktopRestartCapability =
|
||||
!!bridge &&
|
||||
typeof bridge.restartBackend === 'function' &&
|
||||
hasDesktopRuntimeProbe
|
||||
|
||||
let isDesktopRuntime = !!bridge?.isDesktop
|
||||
if (hasDesktopRuntimeProbe) {
|
||||
try {
|
||||
isDesktopRuntime = isDesktopRuntime || !!(await bridge.isDesktopRuntime())
|
||||
} catch (error) {
|
||||
console.warn('[desktop-runtime] Failed to detect desktop runtime.', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bridge,
|
||||
hasDesktopRuntimeProbe,
|
||||
hasDesktopRestartCapability,
|
||||
isDesktopRuntime,
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function getProviderIcon(type) {
|
||||
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
|
||||
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
|
||||
'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg',
|
||||
'openrouter': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg',
|
||||
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
|
||||
"compshare": "https://compshare.cn/favicon.ico"
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { getDesktopRuntimeInfo } from '@/utils/desktopRuntime'
|
||||
|
||||
type WaitingForRestartRef = {
|
||||
check: (initialStartTime?: number | null) => void | Promise<void>
|
||||
@@ -27,9 +28,10 @@ async function fetchCurrentStartTime(): Promise<number | null> {
|
||||
export async function restartAstrBot(
|
||||
waitingRef?: WaitingForRestartRef | null
|
||||
): Promise<void> {
|
||||
const desktopBridge = window.astrbotDesktop
|
||||
const { bridge: desktopBridge, hasDesktopRestartCapability, isDesktopRuntime } =
|
||||
await getDesktopRuntimeInfo()
|
||||
|
||||
if (desktopBridge?.isElectron) {
|
||||
if (desktopBridge && hasDesktopRestartCapability && isDesktopRuntime) {
|
||||
const authToken = localStorage.getItem('token')
|
||||
const initialStartTime = await fetchCurrentStartTime()
|
||||
try {
|
||||
|
||||
@@ -63,10 +63,156 @@
|
||||
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">{{ tm('system.restart.button') }}</v-btn>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-subheader>{{ tm('apiKey.title') }}</v-list-subheader>
|
||||
|
||||
<v-list-item :subtitle="tm('apiKey.subtitle')">
|
||||
<template #title>
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ tm('apiKey.manageTitle') }}</span>
|
||||
<v-tooltip location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
:aria-label="tm('apiKey.docsLink')"
|
||||
href="https://docs.astrbot.app/dev/openapi.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<v-icon size="18">mdi-help-circle-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ tm('apiKey.docsLink') }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<v-row class="mt-2" dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model="newApiKeyName"
|
||||
:label="tm('apiKey.name')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="newApiKeyExpiresInDays"
|
||||
:items="apiKeyExpiryOptions"
|
||||
:label="tm('apiKey.expiresInDays')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col v-if="newApiKeyExpiresInDays === 'permanent'" cols="12">
|
||||
<v-alert type="warning" variant="tonal" density="comfortable">
|
||||
{{ tm('apiKey.permanentWarning') }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
<v-col cols="12" md="5" class="d-flex align-center">
|
||||
<v-btn color="primary" :loading="apiKeyCreating" @click="createApiKey">
|
||||
<v-icon class="mr-2">mdi-key-plus</v-icon>
|
||||
{{ tm('apiKey.create') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<div class="text-caption text-medium-emphasis mb-1">{{ tm('apiKey.scopes') }}</div>
|
||||
<v-chip-group v-model="newApiKeyScopes" multiple>
|
||||
<v-chip
|
||||
v-for="scope in availableScopes"
|
||||
:key="scope.value"
|
||||
:value="scope.value"
|
||||
:color="newApiKeyScopes.includes(scope.value) ? 'primary' : undefined"
|
||||
:variant="newApiKeyScopes.includes(scope.value) ? 'flat' : 'tonal'"
|
||||
>
|
||||
{{ scope.label }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
|
||||
<v-col v-if="createdApiKeyPlaintext" cols="12">
|
||||
<v-alert type="warning" variant="tonal">
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<span>{{ tm('apiKey.plaintextHint') }}</span>
|
||||
<v-btn size="small" variant="text" color="primary" @click="copyCreatedApiKey">
|
||||
<v-icon class="mr-1">mdi-content-copy</v-icon>{{ tm('apiKey.copy') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<code style="word-break: break-all;">{{ createdApiKeyPlaintext }}</code>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tm('apiKey.table.name') }}</th>
|
||||
<th>{{ tm('apiKey.table.prefix') }}</th>
|
||||
<th>{{ tm('apiKey.table.scopes') }}</th>
|
||||
<th>{{ tm('apiKey.table.status') }}</th>
|
||||
<th>{{ tm('apiKey.table.lastUsed') }}</th>
|
||||
<th>{{ tm('apiKey.table.createdAt') }}</th>
|
||||
<th>{{ tm('apiKey.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in apiKeys" :key="item.key_id">
|
||||
<td>{{ item.name }}</td>
|
||||
<td><code>{{ item.key_prefix }}</code></td>
|
||||
<td>{{ (item.scopes || []).join(', ') }}</td>
|
||||
<td>
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="item.is_revoked || item.is_expired ? 'error' : 'success'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.is_revoked || item.is_expired ? tm('apiKey.status.inactive') : tm('apiKey.status.active') }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ formatDate(item.last_used_at) }}</td>
|
||||
<td>{{ formatDate(item.created_at) }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
v-if="!item.is_revoked"
|
||||
size="x-small"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
@click="revokeApiKey(item.key_id)"
|
||||
>
|
||||
{{ tm('apiKey.revoke') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
@click="deleteApiKey(item.key_id)"
|
||||
>
|
||||
{{ tm('apiKey.delete') }}
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="apiKeys.length === 0">
|
||||
<td colspan="7" class="text-center text-medium-emphasis">
|
||||
{{ tm('apiKey.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-list-item :subtitle="tm('system.migration.subtitle')" :title="tm('system.migration.title')">
|
||||
<v-btn style="margin-top: 16px;" color="primary" @click="startMigration">{{ tm('system.migration.button') }}</v-btn>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -77,7 +223,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ProxySelector from '@/components/shared/ProxySelector.vue';
|
||||
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
|
||||
@@ -87,8 +234,10 @@ import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot'
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
import { PurpleTheme } from '@/theme/LightTheme';
|
||||
import { useToastStore } from '@/stores/toast';
|
||||
|
||||
const { tm } = useModuleI18n('features/settings');
|
||||
const toastStore = useToastStore();
|
||||
const theme = useTheme();
|
||||
|
||||
const getStoredColor = (key, fallback) => {
|
||||
@@ -135,6 +284,127 @@ watch(secondaryColor, (value) => {
|
||||
const wfr = ref(null);
|
||||
const migrationDialog = ref(null);
|
||||
const backupDialog = ref(null);
|
||||
const apiKeys = ref([]);
|
||||
const apiKeyCreating = ref(false);
|
||||
const newApiKeyName = ref('');
|
||||
const newApiKeyExpiresInDays = ref(30);
|
||||
const newApiKeyScopes = ref(['chat', 'config', 'file', 'im']);
|
||||
const createdApiKeyPlaintext = ref('');
|
||||
const apiKeyExpiryOptions = computed(() => [
|
||||
{ title: tm('apiKey.expiryOptions.day1'), value: 1 },
|
||||
{ title: tm('apiKey.expiryOptions.day7'), value: 7 },
|
||||
{ title: tm('apiKey.expiryOptions.day30'), value: 30 },
|
||||
{ title: tm('apiKey.expiryOptions.day90'), value: 90 },
|
||||
{ title: tm('apiKey.expiryOptions.permanent'), value: 'permanent' }
|
||||
]);
|
||||
|
||||
const availableScopes = [
|
||||
{ value: 'chat', label: 'chat' },
|
||||
{ value: 'config', label: 'config' },
|
||||
{ value: 'file', label: 'file' },
|
||||
{ value: 'im', label: 'im' }
|
||||
];
|
||||
|
||||
const showToast = (message, color = 'success') => {
|
||||
toastStore.add({
|
||||
message,
|
||||
color,
|
||||
timeout: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-';
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return '-';
|
||||
return dt.toLocaleString();
|
||||
};
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/apikey/list');
|
||||
if (res.data.status !== 'ok') {
|
||||
showToast(res.data.message || tm('apiKey.messages.loadFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
apiKeys.value = res.data.data || [];
|
||||
} catch (e) {
|
||||
showToast(e?.response?.data?.message || tm('apiKey.messages.loadFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const copyCreatedApiKey = async () => {
|
||||
if (!createdApiKeyPlaintext.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdApiKeyPlaintext.value);
|
||||
showToast(tm('apiKey.messages.copySuccess'), 'success');
|
||||
} catch (_) {
|
||||
showToast(tm('apiKey.messages.copyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
const selectedScopes = availableScopes
|
||||
.map((scope) => scope.value)
|
||||
.filter((scope) => newApiKeyScopes.value.includes(scope));
|
||||
|
||||
if (selectedScopes.length === 0) {
|
||||
showToast(tm('apiKey.messages.scopeRequired'), 'warning');
|
||||
return;
|
||||
}
|
||||
apiKeyCreating.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: newApiKeyName.value,
|
||||
scopes: selectedScopes
|
||||
};
|
||||
if (newApiKeyExpiresInDays.value !== 'permanent') {
|
||||
payload.expires_in_days = Number(newApiKeyExpiresInDays.value);
|
||||
}
|
||||
const res = await axios.post('/api/apikey/create', payload);
|
||||
if (res.data.status !== 'ok') {
|
||||
showToast(res.data.message || tm('apiKey.messages.createFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
createdApiKeyPlaintext.value = res.data.data?.api_key || '';
|
||||
newApiKeyName.value = '';
|
||||
newApiKeyExpiresInDays.value = 30;
|
||||
showToast(tm('apiKey.messages.createSuccess'), 'success');
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
showToast(e?.response?.data?.message || tm('apiKey.messages.createFailed'), 'error');
|
||||
} finally {
|
||||
apiKeyCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeApiKey = async (keyId) => {
|
||||
try {
|
||||
const res = await axios.post('/api/apikey/revoke', { key_id: keyId });
|
||||
if (res.data.status !== 'ok') {
|
||||
showToast(res.data.message || tm('apiKey.messages.revokeFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
showToast(tm('apiKey.messages.revokeSuccess'), 'success');
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
showToast(e?.response?.data?.message || tm('apiKey.messages.revokeFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApiKey = async (keyId) => {
|
||||
try {
|
||||
const res = await axios.post('/api/apikey/delete', { key_id: keyId });
|
||||
if (res.data.status !== 'ok') {
|
||||
showToast(res.data.message || tm('apiKey.messages.deleteFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
showToast(tm('apiKey.messages.deleteSuccess'), 'success');
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
showToast(e?.response?.data?.message || tm('apiKey.messages.deleteFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const restartAstrBot = async () => {
|
||||
try {
|
||||
@@ -170,4 +440,8 @@ const resetThemeColors = () => {
|
||||
localStorage.removeItem('themeSecondary');
|
||||
applyThemeColors(primaryColor.value, secondaryColor.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -138,14 +138,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Controls (stop propagation on clicks) -->
|
||||
<div class="d-flex align-center gap-2" @click.stop>
|
||||
<div class="d-flex align-center gap-2 flex-shrink-0" @click.stop>
|
||||
<v-switch
|
||||
v-model="agent.enabled"
|
||||
color="success"
|
||||
hide-details
|
||||
inset
|
||||
density="compact"
|
||||
class="mr-2"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-delete-outline"
|
||||
|
||||
+685
@@ -0,0 +1,685 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "AstrBot Open API",
|
||||
"version": "1.0.0",
|
||||
"description": "Developer HTTP APIs for AstrBot. Use API Key authentication for /api/v1/* endpoints."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:6185"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "Open API",
|
||||
"description": "Developer APIs authenticated by API Key"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/v1/im/bots": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "List bot IDs",
|
||||
"description": "Returns configured bot/platform IDs.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseBotList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/file": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "Upload attachment file",
|
||||
"description": "Upload a file and get attachment_id for later use in chat/message APIs.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseUpload"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/chat": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "Send chat message (SSE)",
|
||||
"description": "Send message to AstrBot chat pipeline and receive streaming SSE response. Reuses /api/chat/send behavior. If session_id/conversation_id is omitted, server will create a new UUID session_id.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ChatSendRequest"
|
||||
},
|
||||
"examples": {
|
||||
"plain": {
|
||||
"value": {
|
||||
"message": "Hello",
|
||||
"username": "alice",
|
||||
"session_id": "my_session_001",
|
||||
"enable_streaming": true
|
||||
}
|
||||
},
|
||||
"multipartMessage": {
|
||||
"value": {
|
||||
"message": [
|
||||
{
|
||||
"type": "plain",
|
||||
"text": "Please analyze this file"
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
|
||||
}
|
||||
],
|
||||
"username": "alice",
|
||||
"session_id": "my_session_001",
|
||||
"selected_provider": "openai_chat_completion",
|
||||
"selected_model": "gpt-4.1-mini",
|
||||
"enable_streaming": true
|
||||
}
|
||||
},
|
||||
"withConfig": {
|
||||
"value": {
|
||||
"message": "Use a specific config for this session",
|
||||
"username": "alice",
|
||||
"session_id": "my_session_001",
|
||||
"config_id": "default",
|
||||
"enable_streaming": true
|
||||
}
|
||||
},
|
||||
"autoSessionWithUsername": {
|
||||
"value": {
|
||||
"message": "hello",
|
||||
"username": "alice",
|
||||
"enable_streaming": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SSE stream",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/chat/sessions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "List chat sessions with pagination",
|
||||
"description": "List chat sessions for the specified username.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page_size",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"minimum": 1,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "platform_id",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Optional platform filter"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Target username."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseChatSessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/im/message": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "Send proactive message to a platform bot",
|
||||
"description": "Send message directly to platform bot by umo + message chain payload.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SendMessageRequest"
|
||||
},
|
||||
"examples": {
|
||||
"plain": {
|
||||
"value": {
|
||||
"umo": "webchat:FriendMessage:openapi_probe",
|
||||
"message": "ping from api key"
|
||||
}
|
||||
},
|
||||
"chain": {
|
||||
"value": {
|
||||
"umo": "webchat:FriendMessage:openapi_probe",
|
||||
"message": [
|
||||
{
|
||||
"type": "plain",
|
||||
"text": "hello"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseEmpty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/configs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "List available chat config files",
|
||||
"description": "Returns all available AstrBot config files that can be selected by Chat API using config_id/config_name.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseChatConfigList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"ApiKeyHeader": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "Open API key. Authorization: Bearer <api_key> is also accepted."
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"Unauthorized": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"ApiResponseEmpty": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponseBotList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bot_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponseUpload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"attachment_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponseChatSessions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChatSessionItem"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChatSessionItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"platform_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"creator": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_group": {
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MessagePart": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"plain",
|
||||
"reply",
|
||||
"image",
|
||||
"record",
|
||||
"file",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"message_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"integer"
|
||||
]
|
||||
},
|
||||
"selected_text": {
|
||||
"type": "string"
|
||||
},
|
||||
"attachment_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"ChatSendRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MessagePart"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "Optional chat session ID. If omitted (and conversation_id is also omitted), server creates a UUID automatically."
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"description": "Alias of session_id."
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Target username."
|
||||
},
|
||||
"selected_provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"selected_model": {
|
||||
"type": "string"
|
||||
},
|
||||
"enable_streaming": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"config_id": {
|
||||
"type": "string",
|
||||
"description": "Optional AstrBot config file ID. If provided, the chat session will use this config file. Use \"default\" to reset to default config."
|
||||
},
|
||||
"config_name": {
|
||||
"type": "string",
|
||||
"description": "Optional AstrBot config file name. Used only when config_id is not provided."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SendMessageRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"umo",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"umo": {
|
||||
"type": "string",
|
||||
"description": "Unified message origin. Format: platform:message_type:session_id"
|
||||
},
|
||||
"message": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MessagePart"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChatConfigFile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_default": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"path",
|
||||
"is_default"
|
||||
]
|
||||
},
|
||||
"ApiResponseChatConfigList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChatConfigFile"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.17.6"
|
||||
version = "4.18.1"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from quart import Quart, g, request
|
||||
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.dashboard.routes.route import Response
|
||||
from astrbot.dashboard.server import AstrBotDashboard
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="module")
|
||||
async def core_lifecycle_td(tmp_path_factory):
|
||||
tmp_db_path = tmp_path_factory.mktemp("data") / "test_data_api_key.db"
|
||||
db = SQLiteDatabase(str(tmp_db_path))
|
||||
log_broker = LogBroker()
|
||||
core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
|
||||
await core_lifecycle.initialize()
|
||||
try:
|
||||
yield core_lifecycle
|
||||
finally:
|
||||
try:
|
||||
stop_result = core_lifecycle.stop()
|
||||
if asyncio.iscoroutine(stop_result):
|
||||
await stop_result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app(core_lifecycle_td: AstrBotCoreLifecycle):
|
||||
shutdown_event = asyncio.Event()
|
||||
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
|
||||
return server.app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="module")
|
||||
async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle):
|
||||
test_client = app.test_client()
|
||||
response = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": core_lifecycle_td.astrbot_config["dashboard"]["password"],
|
||||
},
|
||||
)
|
||||
data = await response.get_json()
|
||||
token = data["data"]["token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_scope_and_revoke(app: Quart, authenticated_header: dict):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "im-scope-key", "scopes": ["im"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert create_res.status_code == 200
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
key_id = create_data["data"]["key_id"]
|
||||
|
||||
open_bot_res = await test_client.get(
|
||||
"/api/v1/im/bots",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert open_bot_res.status_code == 200
|
||||
open_bot_data = await open_bot_res.get_json()
|
||||
assert open_bot_data["status"] == "ok"
|
||||
assert isinstance(open_bot_data["data"]["bot_ids"], list)
|
||||
|
||||
denied_chat_sessions_res = await test_client.get(
|
||||
"/api/v1/chat/sessions?page=1&page_size=10",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert denied_chat_sessions_res.status_code == 403
|
||||
|
||||
denied_chat_configs_res = await test_client.get(
|
||||
"/api/v1/configs",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert denied_chat_configs_res.status_code == 403
|
||||
|
||||
denied_res = await test_client.post(
|
||||
"/api/v1/file",
|
||||
data={},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert denied_res.status_code == 403
|
||||
|
||||
revoke_res = await test_client.post(
|
||||
"/api/apikey/revoke",
|
||||
json={"key_id": key_id},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert revoke_res.status_code == 200
|
||||
revoke_data = await revoke_res.get_json()
|
||||
assert revoke_data["status"] == "ok"
|
||||
|
||||
revoked_access_res = await test_client.get(
|
||||
"/api/v1/im/bots",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert revoked_access_res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_send_message_with_api_key(app: Quart, authenticated_header: dict):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "send-message-key", "scopes": ["im"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
|
||||
send_res = await test_client.post(
|
||||
"/api/v1/im/message",
|
||||
json={
|
||||
"umo": "webchat:FriendMessage:open_api_test_session",
|
||||
"message": "hello",
|
||||
},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert send_res.status_code == 200
|
||||
send_data = await send_res.get_json()
|
||||
assert send_data["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_chat_send_auto_session_id_and_username(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "chat-send-key", "scopes": ["chat"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
|
||||
rule = next(
|
||||
(
|
||||
item
|
||||
for item in app.url_map.iter_rules()
|
||||
if item.rule == "/api/v1/chat" and "POST" in item.methods
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert rule is not None
|
||||
open_api_route = app.view_functions[rule.endpoint].__self__
|
||||
|
||||
original_chat = open_api_route.chat_route.chat
|
||||
|
||||
async def fake_chat(post_data: dict | None = None):
|
||||
payload = post_data or await request.get_json()
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"session_id": payload.get("session_id"),
|
||||
"creator": g.get("username"),
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
open_api_route.chat_route.chat = fake_chat
|
||||
try:
|
||||
send_res = await test_client.post(
|
||||
"/api/v1/chat",
|
||||
json={
|
||||
"message": "hello",
|
||||
"username": "alice",
|
||||
"enable_streaming": False,
|
||||
},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
finally:
|
||||
open_api_route.chat_route.chat = original_chat
|
||||
|
||||
assert send_res.status_code == 200
|
||||
send_data = await send_res.get_json()
|
||||
assert send_data["status"] == "ok"
|
||||
created_session_id = send_data["data"]["session_id"]
|
||||
assert isinstance(created_session_id, str)
|
||||
uuid.UUID(created_session_id)
|
||||
assert send_data["data"]["creator"] == "alice"
|
||||
created_session = await core_lifecycle_td.db.get_platform_session_by_id(
|
||||
created_session_id
|
||||
)
|
||||
assert created_session is not None
|
||||
assert created_session.creator == "alice"
|
||||
assert created_session.platform_id == "webchat"
|
||||
|
||||
await core_lifecycle_td.db.create_platform_session(
|
||||
creator="bob",
|
||||
platform_id="webchat",
|
||||
session_id="open_api_existing_bob_session",
|
||||
is_group=0,
|
||||
)
|
||||
another_user_session_res = await test_client.post(
|
||||
"/api/v1/chat",
|
||||
json={
|
||||
"message": "hello",
|
||||
"username": "alice",
|
||||
"session_id": "open_api_existing_bob_session",
|
||||
"enable_streaming": False,
|
||||
},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
another_user_session_data = await another_user_session_res.get_json()
|
||||
assert another_user_session_data["status"] == "error"
|
||||
assert (
|
||||
another_user_session_data["message"]
|
||||
== "session_id belongs to another username"
|
||||
)
|
||||
|
||||
missing_username_res = await test_client.post(
|
||||
"/api/v1/chat",
|
||||
json={"message": "hello"},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
missing_username_data = await missing_username_res.get_json()
|
||||
assert missing_username_data["status"] == "error"
|
||||
assert missing_username_data["message"] == "Missing key: username"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_chat_sessions_pagination(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "chat-scope-key", "scopes": ["chat"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
|
||||
creator = "alice"
|
||||
for idx in range(3):
|
||||
await core_lifecycle_td.db.create_platform_session(
|
||||
creator=creator,
|
||||
platform_id="webchat",
|
||||
session_id=f"open_api_paginated_{idx}",
|
||||
display_name=f"Open API Session {idx}",
|
||||
is_group=0,
|
||||
)
|
||||
await core_lifecycle_td.db.create_platform_session(
|
||||
creator="bob",
|
||||
platform_id="webchat",
|
||||
session_id="open_api_paginated_bob",
|
||||
display_name="Open API Session Bob",
|
||||
is_group=0,
|
||||
)
|
||||
|
||||
page_1_res = await test_client.get(
|
||||
"/api/v1/chat/sessions?page=1&page_size=2&username=alice",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert page_1_res.status_code == 200
|
||||
page_1_data = await page_1_res.get_json()
|
||||
assert page_1_data["status"] == "ok"
|
||||
assert page_1_data["data"]["page"] == 1
|
||||
assert page_1_data["data"]["page_size"] == 2
|
||||
assert page_1_data["data"]["total"] == 3
|
||||
assert len(page_1_data["data"]["sessions"]) == 2
|
||||
assert all(item["creator"] == "alice" for item in page_1_data["data"]["sessions"])
|
||||
|
||||
page_2_res = await test_client.get(
|
||||
"/api/v1/chat/sessions?page=2&page_size=2&username=alice",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert page_2_res.status_code == 200
|
||||
page_2_data = await page_2_res.get_json()
|
||||
assert page_2_data["status"] == "ok"
|
||||
assert page_2_data["data"]["page"] == 2
|
||||
assert len(page_2_data["data"]["sessions"]) == 1
|
||||
|
||||
missing_username_res = await test_client.get(
|
||||
"/api/v1/chat/sessions?page=1&page_size=2",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
missing_username_data = await missing_username_res.get_json()
|
||||
assert missing_username_data["status"] == "error"
|
||||
assert missing_username_data["message"] == "Missing key: username"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_chat_configs_list(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "chat-config-key", "scopes": ["config"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
|
||||
configs_res = await test_client.get(
|
||||
"/api/v1/configs",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert configs_res.status_code == 200
|
||||
configs_data = await configs_res.get_json()
|
||||
assert configs_data["status"] == "ok"
|
||||
assert isinstance(configs_data["data"]["configs"], list)
|
||||
assert any(item["id"] == "default" for item in configs_data["data"]["configs"])
|
||||
@@ -105,6 +105,28 @@ class MockErrProvider(MockProvider):
|
||||
)
|
||||
|
||||
|
||||
class MockAbortableStreamProvider(MockProvider):
|
||||
async def text_chat_stream(self, **kwargs):
|
||||
abort_signal = kwargs.get("abort_signal")
|
||||
yield LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="partial ",
|
||||
is_chunk=True,
|
||||
)
|
||||
if abort_signal and abort_signal.is_set():
|
||||
yield LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="partial ",
|
||||
is_chunk=False,
|
||||
)
|
||||
return
|
||||
yield LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="partial final",
|
||||
is_chunk=False,
|
||||
)
|
||||
|
||||
|
||||
class MockHooks(BaseAgentRunHooks):
|
||||
"""模拟钩子函数"""
|
||||
|
||||
@@ -394,6 +416,41 @@ async def test_fallback_provider_used_when_primary_returns_err(
|
||||
assert fallback_provider.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_signal_returns_aborted_and_persists_partial_message(
|
||||
runner, provider_request, mock_tool_executor, mock_hooks
|
||||
):
|
||||
provider = MockAbortableStreamProvider()
|
||||
|
||||
await runner.reset(
|
||||
provider=provider,
|
||||
request=provider_request,
|
||||
run_context=ContextWrapper(context=None),
|
||||
tool_executor=mock_tool_executor,
|
||||
agent_hooks=mock_hooks,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
step_iter = runner.step()
|
||||
first_resp = await step_iter.__anext__()
|
||||
assert first_resp.type == "streaming_delta"
|
||||
|
||||
runner.request_stop()
|
||||
|
||||
rest_responses = []
|
||||
async for response in step_iter:
|
||||
rest_responses.append(response)
|
||||
|
||||
assert any(resp.type == "aborted" for resp in rest_responses)
|
||||
assert runner.was_aborted() is True
|
||||
|
||||
final_resp = runner.get_final_llm_resp()
|
||||
assert final_resp is not None
|
||||
assert final_resp.role == "assistant"
|
||||
assert final_resp.completion_text == "partial "
|
||||
assert runner.run_context.messages[-1].role == "assistant"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
Reference in New Issue
Block a user