Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 565c371e5c | |||
| a1c9dc5d01 | |||
| d3d4e1db7b | |||
| 78b3e12c66 | |||
| c42ac87ee1 | |||
| 3fbd16b211 | |||
| e77500ff69 | |||
| 2c49ac0dcf | |||
| 65decfbe87 | |||
| 92c31192de | |||
| b795f804a7 | |||
| bc3b5e58a4 | |||
| 7e3c32b828 | |||
| ceb32dce9f | |||
| 84e880af5f | |||
| 9909d774ed | |||
| 6b3868b4be | |||
| 11c840953a | |||
| 2bbca887ce | |||
| dd89a4b334 | |||
| a3fa8a5a7c | |||
| aa60467782 | |||
| d936bb0a10 | |||
| 64e0183b55 | |||
| 420d82df11 | |||
| d87cf897da | |||
| 2f51916a73 | |||
| b0e10cf479 | |||
| 20efaa5320 | |||
| 3ccd70cd4e | |||
| da520e573a | |||
| 6d055e81e9 | |||
| d41ccb70c5 | |||
| 18a99a25c2 | |||
| 96cafe001d | |||
| 29d100dd83 | |||
| 14f3701c4a | |||
| 1044fc48ca | |||
| 693c2ca818 | |||
| b1c486ba98 | |||
| 9363fb824a | |||
| 044b361ac5 | |||
| 06fd2d2428 | |||
| dd6bc1dcdb | |||
| 52d5258b10 | |||
| 91933bbd19 | |||
| f8d075b5d3 | |||
| 86ef758a9a | |||
| 1a03180643 | |||
| 326183a3fd | |||
| 08fc657755 | |||
| 0ff9539599 | |||
| 38f5e077ee | |||
| 89fbd75e7a | |||
| 493662524a | |||
| 1afbb357db | |||
| 8d2140f607 | |||
| 97732987d9 | |||
| a60a40bca3 | |||
| a8ff2b3d9c |
@@ -21,7 +21,23 @@
|
|||||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||||
|
|
||||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
|
||||||
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||||
|
/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||||
|
|
||||||
|
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||||
|
/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||||
|
|
||||||
|
- [ ] 😮 我的更改没有引入恶意代码。
|
||||||
|
/ My changes do not introduce malicious code.
|
||||||
|
|
||||||
|
- [ ] ⚠️ 我已认真阅读并理解以上所有内容,确保本次提交符合规范。
|
||||||
|
/ I have read and understood all the above and confirm this PR follows the rules.
|
||||||
|
|
||||||
|
- [ ] 🚀 我确保本次开发**基于 dev 分支**,并将代码合并至**开发分支**(除非极其紧急,才允许合并到主分支)。
|
||||||
|
/ I confirm that this development is **based on the dev branch** and will be merged into the **development branch**, unless it is extremely urgent to merge into the main branch.
|
||||||
|
|
||||||
|
- [ ] ⚠️ 我**没有**认真阅读以上内容,直接提交。
|
||||||
|
/ I **did not** read the above carefully before submitting.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: PR Checklist Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, reopened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check checklist
|
||||||
|
id: check
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const body = context.payload.pull_request.body || "";
|
||||||
|
const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i;
|
||||||
|
const bad = regex.test(body);
|
||||||
|
core.setOutput("bad", bad);
|
||||||
|
|
||||||
|
- name: Close PR
|
||||||
|
if: steps.check.outputs.bad == 'true'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。`
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: pr.number,
|
||||||
|
state: "closed"
|
||||||
|
});
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4.3.0
|
uses: pnpm/action-setup@v4.4.0
|
||||||
with:
|
with:
|
||||||
version: 10.28.2
|
version: 10.28.2
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # Only execute this command for the first time to initialize the environment
|
astrbot init # Only execute this command for the first time to initialize the environment
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # 初回のみ実行して環境を初期化します
|
astrbot init # 初回のみ実行して環境を初期化します
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # 僅首次執行此命令以初始化環境
|
astrbot init # 僅首次執行此命令以初始化環境
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # 仅首次执行此命令以初始化环境
|
astrbot init # 仅首次执行此命令以初始化环境
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ async def run_live_agent(
|
|||||||
|
|
||||||
# 创建队列
|
# 创建队列
|
||||||
text_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
text_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
|
delta_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
# audio_queue stored bytes or (text, bytes)
|
# audio_queue stored bytes or (text, bytes)
|
||||||
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
|
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
|
||||||
|
|
||||||
@@ -334,6 +335,7 @@ async def run_live_agent(
|
|||||||
_run_agent_feeder(
|
_run_agent_feeder(
|
||||||
agent_runner,
|
agent_runner,
|
||||||
text_queue,
|
text_queue,
|
||||||
|
delta_queue,
|
||||||
max_step,
|
max_step,
|
||||||
show_tool_use,
|
show_tool_use,
|
||||||
show_tool_call_result,
|
show_tool_call_result,
|
||||||
@@ -353,32 +355,63 @@ async def run_live_agent(
|
|||||||
|
|
||||||
# 3. 主循环:从 audio_queue 读取音频并 yield
|
# 3. 主循环:从 audio_queue 读取音频并 yield
|
||||||
try:
|
try:
|
||||||
while True:
|
delta_done = False
|
||||||
queue_item = await audio_queue.get()
|
audio_done = False
|
||||||
|
while not (delta_done and audio_done):
|
||||||
|
task_sources: dict[asyncio.Task, str] = {}
|
||||||
|
if not delta_done:
|
||||||
|
task = asyncio.create_task(delta_queue.get())
|
||||||
|
task_sources[task] = "delta"
|
||||||
|
if not audio_done:
|
||||||
|
task = asyncio.create_task(audio_queue.get())
|
||||||
|
task_sources[task] = "audio"
|
||||||
|
|
||||||
if queue_item is None:
|
done, pending = await asyncio.wait(
|
||||||
break
|
list(task_sources),
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
text = None
|
for task in pending:
|
||||||
if isinstance(queue_item, tuple):
|
task.cancel()
|
||||||
text, audio_data = queue_item
|
if pending:
|
||||||
else:
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
audio_data = queue_item
|
|
||||||
|
|
||||||
if not first_chunk_received:
|
for task in done:
|
||||||
# 记录首帧延迟(从开始处理到收到第一个音频块)
|
source = task_sources[task]
|
||||||
tts_first_frame_time = time.time() - tts_start_time
|
queue_item = task.result()
|
||||||
first_chunk_received = True
|
if source == "delta":
|
||||||
|
if queue_item is None:
|
||||||
|
delta_done = True
|
||||||
|
continue
|
||||||
|
yield MessageChain(
|
||||||
|
chain=[Plain(queue_item)], type="live_text_delta"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# 将音频数据封装为 MessageChain
|
if queue_item is None:
|
||||||
import base64
|
audio_done = True
|
||||||
|
continue
|
||||||
|
|
||||||
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
|
text = None
|
||||||
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
|
if isinstance(queue_item, tuple):
|
||||||
if text:
|
text, audio_data = queue_item
|
||||||
comps.append(Json(data={"text": text}))
|
else:
|
||||||
chain = MessageChain(chain=comps, type="audio_chunk")
|
audio_data = queue_item
|
||||||
yield chain
|
|
||||||
|
if not first_chunk_received:
|
||||||
|
# 记录首帧延迟(从开始处理到收到第一个音频块)
|
||||||
|
tts_first_frame_time = time.time() - tts_start_time
|
||||||
|
first_chunk_received = True
|
||||||
|
|
||||||
|
# 将音频数据封装为 MessageChain
|
||||||
|
import base64
|
||||||
|
|
||||||
|
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
|
||||||
|
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
|
||||||
|
if text:
|
||||||
|
comps.append(Json(data={"text": text}))
|
||||||
|
chain = MessageChain(chain=comps, type="audio_chunk")
|
||||||
|
yield chain
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
|
logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
|
||||||
@@ -421,6 +454,7 @@ async def run_live_agent(
|
|||||||
async def _run_agent_feeder(
|
async def _run_agent_feeder(
|
||||||
agent_runner: AgentRunner,
|
agent_runner: AgentRunner,
|
||||||
text_queue: asyncio.Queue,
|
text_queue: asyncio.Queue,
|
||||||
|
delta_queue: asyncio.Queue,
|
||||||
max_step: int,
|
max_step: int,
|
||||||
show_tool_use: bool,
|
show_tool_use: bool,
|
||||||
show_tool_call_result: bool,
|
show_tool_call_result: bool,
|
||||||
@@ -440,9 +474,13 @@ async def _run_agent_feeder(
|
|||||||
if chain is None:
|
if chain is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if chain.type == "reasoning":
|
||||||
|
continue
|
||||||
|
|
||||||
# 提取文本
|
# 提取文本
|
||||||
text = chain.get_plain_text()
|
text = chain.get_plain_text()
|
||||||
if text:
|
if text:
|
||||||
|
await delta_queue.put(text)
|
||||||
buffer += text
|
buffer += text
|
||||||
|
|
||||||
# 分句逻辑:匹配标点符号
|
# 分句逻辑:匹配标点符号
|
||||||
@@ -477,6 +515,7 @@ async def _run_agent_feeder(
|
|||||||
finally:
|
finally:
|
||||||
# 发送结束信号
|
# 发送结束信号
|
||||||
await text_queue.put(None)
|
await text_queue.put(None)
|
||||||
|
await delta_queue.put(None)
|
||||||
|
|
||||||
|
|
||||||
async def _safe_tts_stream_wrapper(
|
async def _safe_tts_stream_wrapper(
|
||||||
|
|||||||
@@ -778,9 +778,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
|||||||
continue
|
continue
|
||||||
mp = tool.handler_module_path
|
mp = tool.handler_module_path
|
||||||
if not mp:
|
if not mp:
|
||||||
|
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*)
|
||||||
|
# 不应受到会话插件过滤影响。
|
||||||
|
new_tool_set.add_tool(tool)
|
||||||
continue
|
continue
|
||||||
plugin = star_map.get(mp)
|
plugin = star_map.get(mp)
|
||||||
if not plugin:
|
if not plugin:
|
||||||
|
# 无法解析插件归属时,保守保留工具,避免误过滤。
|
||||||
|
new_tool_set.add_tool(tool)
|
||||||
continue
|
continue
|
||||||
if plugin.name in event.plugins_name or plugin.reserved:
|
if plugin.name in event.plugins_name or plugin.reserved:
|
||||||
new_tool_set.add_tool(tool)
|
new_tool_set.add_tool(tool)
|
||||||
|
|||||||
@@ -188,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||||
name: str = "send_message_to_user"
|
name: str = "send_message_to_user"
|
||||||
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
|
description: str = (
|
||||||
|
"Send message to the user. "
|
||||||
|
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
|
||||||
|
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
|
||||||
|
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
|
||||||
|
)
|
||||||
|
|
||||||
parameters: dict = Field(
|
parameters: dict = Field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: {
|
||||||
|
|||||||
@@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"payload": {
|
"payload": {
|
||||||
"anyOf": [{"type": "object"}, {"type": "array"}],
|
"anyOf": [
|
||||||
|
{"type": "object"},
|
||||||
|
{"type": "array", "items": {"type": "object"}},
|
||||||
|
],
|
||||||
"description": (
|
"description": (
|
||||||
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
|
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
|
||||||
"This only stores content and returns payload_ref; it does not create a candidate or release."
|
"This only stores content and returns payload_ref; it does not create a candidate or release."
|
||||||
|
|||||||
@@ -1132,6 +1132,18 @@ CONFIG_METADATA_2 = {
|
|||||||
"proxy": "",
|
"proxy": "",
|
||||||
"custom_headers": {},
|
"custom_headers": {},
|
||||||
},
|
},
|
||||||
|
"MiniMax": {
|
||||||
|
"id": "minimax",
|
||||||
|
"provider": "minimax",
|
||||||
|
"type": "openai_chat_completion",
|
||||||
|
"provider_type": "chat_completion",
|
||||||
|
"enable": True,
|
||||||
|
"key": [],
|
||||||
|
"api_base": "https://api.minimaxi.com/v1",
|
||||||
|
"timeout": 120,
|
||||||
|
"proxy": "",
|
||||||
|
"custom_headers": {},
|
||||||
|
},
|
||||||
"xAI": {
|
"xAI": {
|
||||||
"id": "xai",
|
"id": "xai",
|
||||||
"provider": "xai",
|
"provider": "xai",
|
||||||
|
|||||||
@@ -332,9 +332,9 @@ class CronJobManager:
|
|||||||
cron_job=cron_job_str
|
cron_job=cron_job_str
|
||||||
)
|
)
|
||||||
req.prompt = (
|
req.prompt = (
|
||||||
"You are now responding to a scheduled task"
|
"You are now responding to a scheduled task. "
|
||||||
"Proceed according to your system instructions. "
|
"Proceed according to your system instructions. "
|
||||||
"Output using same language as previous conversation."
|
"Output using same language as previous conversation. "
|
||||||
"After completing your task, summarize and output your actions and results."
|
"After completing your task, summarize and output your actions and results."
|
||||||
)
|
)
|
||||||
if not req.func_tool:
|
if not req.func_tool:
|
||||||
|
|||||||
@@ -647,6 +647,13 @@ class BaseDatabase(abc.ABC):
|
|||||||
"""Get a Platform session by its ID."""
|
"""Get a Platform session by its ID."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def get_platform_sessions_by_ids(
|
||||||
|
self, session_ids: list[str]
|
||||||
|
) -> list[PlatformSession]:
|
||||||
|
"""Get platform sessions by IDs."""
|
||||||
|
...
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def get_platform_sessions_by_creator(
|
async def get_platform_sessions_by_creator(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
|
|||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_platform_sessions_by_ids(
|
||||||
|
self, session_ids: list[str]
|
||||||
|
) -> list[PlatformSession]:
|
||||||
|
"""Get platform sessions by IDs."""
|
||||||
|
if not session_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
query = select(PlatformSession).where(
|
||||||
|
col(PlatformSession.session_id).in_(session_ids)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def get_platform_sessions_by_creator(
|
async def get_platform_sessions_by_creator(
|
||||||
self,
|
self,
|
||||||
creator: str,
|
creator: str,
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
|
|||||||
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
||||||
super().__init__(text=text, convert=convert, **_)
|
super().__init__(text=text, convert=convert, **_)
|
||||||
|
|
||||||
def toDict(self):
|
def toDict(self) -> dict:
|
||||||
return {"type": "text", "data": {"text": self.text.strip()}}
|
return {"type": "text", "data": {"text": self.text}}
|
||||||
|
|
||||||
async def to_dict(self):
|
async def to_dict(self) -> dict:
|
||||||
return {"type": "text", "data": {"text": self.text}}
|
return {"type": "text", "data": {"text": self.text}}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
|
|||||||
|
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||||
from astrbot.api.message_components import (
|
from astrbot.api.message_components import (
|
||||||
|
At,
|
||||||
BaseMessageComponent,
|
BaseMessageComponent,
|
||||||
File,
|
File,
|
||||||
Image,
|
Image,
|
||||||
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
|||||||
"""解析成 OneBot json 格式"""
|
"""解析成 OneBot json 格式"""
|
||||||
ret = []
|
ret = []
|
||||||
for segment in message_chain.chain:
|
for segment in message_chain.chain:
|
||||||
if isinstance(segment, Plain):
|
if isinstance(segment, At):
|
||||||
|
# At 组件后插入一个空格,避免与后续文本粘连
|
||||||
|
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||||
|
ret.append(d)
|
||||||
|
ret.append({"type": "text", "data": {"text": " "}})
|
||||||
|
elif isinstance(segment, Plain):
|
||||||
if not segment.text.strip():
|
if not segment.text.strip():
|
||||||
continue
|
continue
|
||||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||||
ret.append(d)
|
ret.append(d)
|
||||||
|
else:
|
||||||
|
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||||
|
ret.append(d)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -391,6 +391,47 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
else:
|
else:
|
||||||
msg.append(File(name=filename, file=url, url=url))
|
msg.append(File(name=filename, file=url, url=url))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_face_message(content: str) -> str:
|
||||||
|
"""Parse QQ official face message format and convert to readable text.
|
||||||
|
|
||||||
|
QQ official face message format:
|
||||||
|
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
|
||||||
|
|
||||||
|
The ext field contains base64-encoded JSON with a 'text' field
|
||||||
|
describing the emoji (e.g., '[满头问号]').
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The message content that may contain face tags.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Content with face tags replaced by readable emoji descriptions.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
def replace_face(match):
|
||||||
|
face_tag = match.group(0)
|
||||||
|
# Extract ext field from the face tag
|
||||||
|
ext_match = re.search(r'ext="([^"]*)"', face_tag)
|
||||||
|
if ext_match:
|
||||||
|
try:
|
||||||
|
ext_encoded = ext_match.group(1)
|
||||||
|
# Decode base64 and parse JSON
|
||||||
|
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
|
||||||
|
ext_data = json.loads(ext_decoded)
|
||||||
|
emoji_text = ext_data.get("text", "")
|
||||||
|
if emoji_text:
|
||||||
|
return f"[表情:{emoji_text}]"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback if parsing fails
|
||||||
|
return "[表情]"
|
||||||
|
|
||||||
|
# Match face tags: <faceType=...>
|
||||||
|
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_from_qqofficial(
|
def _parse_from_qqofficial(
|
||||||
message: botpy.message.Message
|
message: botpy.message.Message
|
||||||
@@ -416,7 +457,10 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
abm.group_id = message.group_openid
|
abm.group_id = message.group_openid
|
||||||
else:
|
else:
|
||||||
abm.sender = MessageMember(message.author.user_openid, "")
|
abm.sender = MessageMember(message.author.user_openid, "")
|
||||||
abm.message_str = message.content.strip()
|
# Parse face messages to readable text
|
||||||
|
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
|
||||||
|
message.content.strip()
|
||||||
|
)
|
||||||
abm.self_id = "unknown_selfid"
|
abm.self_id = "unknown_selfid"
|
||||||
msg.append(At(qq="qq_official"))
|
msg.append(At(qq="qq_official"))
|
||||||
msg.append(Plain(abm.message_str))
|
msg.append(Plain(abm.message_str))
|
||||||
@@ -432,10 +476,12 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
else:
|
else:
|
||||||
abm.self_id = ""
|
abm.self_id = ""
|
||||||
|
|
||||||
plain_content = message.content.replace(
|
plain_content = QQOfficialPlatformAdapter._parse_face_message(
|
||||||
"<@!" + str(abm.self_id) + ">",
|
message.content.replace(
|
||||||
"",
|
"<@!" + str(abm.self_id) + ">",
|
||||||
).strip()
|
"",
|
||||||
|
).strip()
|
||||||
|
)
|
||||||
|
|
||||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||||
abm.message = msg
|
abm.message = msg
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
@@ -39,6 +40,9 @@ class QQOfficialWebhook:
|
|||||||
self.client = botpy_client
|
self.client = botpy_client
|
||||||
self.event_queue = event_queue
|
self.event_queue = event_queue
|
||||||
self.shutdown_event = asyncio.Event()
|
self.shutdown_event = asyncio.Event()
|
||||||
|
# Deduplication cache for webhook retry callbacks.
|
||||||
|
self._seen_event_ids: dict[str, float] = {}
|
||||||
|
self._dedup_ttl: int = 60 # seconds
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
logger.info("正在登录到 QQ 官方机器人...")
|
logger.info("正在登录到 QQ 官方机器人...")
|
||||||
@@ -106,6 +110,22 @@ class QQOfficialWebhook:
|
|||||||
print(signed)
|
print(signed)
|
||||||
return signed
|
return signed
|
||||||
|
|
||||||
|
event_id = msg.get("id")
|
||||||
|
if event_id:
|
||||||
|
now = time.monotonic()
|
||||||
|
# Lazily evict expired entries to prevent unbounded growth.
|
||||||
|
expired = [
|
||||||
|
k
|
||||||
|
for k, ts in self._seen_event_ids.items()
|
||||||
|
if now - ts > self._dedup_ttl
|
||||||
|
]
|
||||||
|
for k in expired:
|
||||||
|
del self._seen_event_ids[k]
|
||||||
|
if event_id in self._seen_event_ids:
|
||||||
|
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
|
||||||
|
return {"opcode": 12}
|
||||||
|
self._seen_event_ids[event_id] = now
|
||||||
|
|
||||||
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
|
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
|
||||||
event = msg["t"].lower()
|
event = msg["t"].lower()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
|
|||||||
else:
|
else:
|
||||||
message.type = MessageType.GROUP_MESSAGE
|
message.type = MessageType.GROUP_MESSAGE
|
||||||
message.group_id = str(update.message.chat.id)
|
message.group_id = str(update.message.chat.id)
|
||||||
if update.message.message_thread_id:
|
if update.message.is_topic_message and update.message.message_thread_id:
|
||||||
# Topic Group
|
# Telegram Topic Group: include thread id to isolate per-topic sessions.
|
||||||
message.group_id += "#" + str(update.message.message_thread_id)
|
message.group_id += "#" + str(update.message.message_thread_id)
|
||||||
message.session_id = message.group_id
|
message.session_id = message.group_id
|
||||||
message.message_id = str(update.message.message_id)
|
message.message_id = str(update.message.message_id)
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
|||||||
from astrbot.core.utils.metrics import Metric
|
from astrbot.core.utils.metrics import Metric
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gif(path: str) -> bool:
|
||||||
|
if path.lower().endswith(".gif"):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read(6) in (b"GIF87a", b"GIF89a")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class TelegramPlatformEvent(AstrMessageEvent):
|
class TelegramPlatformEvent(AstrMessageEvent):
|
||||||
# Telegram 的最大消息长度限制
|
# Telegram 的最大消息长度限制
|
||||||
MAX_MESSAGE_LENGTH = 4096
|
MAX_MESSAGE_LENGTH = 4096
|
||||||
@@ -291,7 +301,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
await client.send_message(text=chunk, **cast(Any, payload))
|
await client.send_message(text=chunk, **cast(Any, payload))
|
||||||
elif isinstance(i, Image):
|
elif isinstance(i, Image):
|
||||||
image_path = await i.convert_to_file_path()
|
image_path = await i.convert_to_file_path()
|
||||||
await client.send_photo(photo=image_path, **cast(Any, payload))
|
if _is_gif(image_path):
|
||||||
|
send_coro = client.send_animation
|
||||||
|
media_kwarg = {"animation": image_path}
|
||||||
|
else:
|
||||||
|
send_coro = client.send_photo
|
||||||
|
media_kwarg = {"photo": image_path}
|
||||||
|
await send_coro(**media_kwarg, **cast(Any, payload))
|
||||||
elif isinstance(i, File):
|
elif isinstance(i, File):
|
||||||
path = await i.get_file()
|
path = await i.get_file()
|
||||||
name = i.name or os.path.basename(path)
|
name = i.name or os.path.basename(path)
|
||||||
@@ -406,12 +422,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
on_text(i.text)
|
on_text(i.text)
|
||||||
elif isinstance(i, Image):
|
elif isinstance(i, Image):
|
||||||
image_path = await i.convert_to_file_path()
|
image_path = await i.convert_to_file_path()
|
||||||
|
if _is_gif(image_path):
|
||||||
|
action = ChatAction.UPLOAD_VIDEO
|
||||||
|
send_coro = self.client.send_animation
|
||||||
|
media_kwarg = {"animation": image_path}
|
||||||
|
else:
|
||||||
|
action = ChatAction.UPLOAD_PHOTO
|
||||||
|
send_coro = self.client.send_photo
|
||||||
|
media_kwarg = {"photo": image_path}
|
||||||
await self._send_media_with_action(
|
await self._send_media_with_action(
|
||||||
self.client,
|
self.client,
|
||||||
ChatAction.UPLOAD_PHOTO,
|
action,
|
||||||
self.client.send_photo,
|
send_coro,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
photo=image_path,
|
**media_kwarg,
|
||||||
**cast(Any, payload),
|
**cast(Any, payload),
|
||||||
)
|
)
|
||||||
elif isinstance(i, File):
|
elif isinstance(i, File):
|
||||||
|
|||||||
@@ -440,9 +440,16 @@ class WecomAIBotAdapter(Platform):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
||||||
"""从消息数据中提取会话ID"""
|
"""从消息数据中提取会话ID
|
||||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
群聊使用 chatid,单聊使用 userid
|
||||||
return format_session_id("wecomai", user_id)
|
"""
|
||||||
|
chattype = message_data.get("chattype", "single")
|
||||||
|
if chattype == "group":
|
||||||
|
chat_id = message_data.get("chatid", "default_group")
|
||||||
|
return format_session_id("wecomai", chat_id)
|
||||||
|
else:
|
||||||
|
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||||
|
return format_session_id("wecomai", user_id)
|
||||||
|
|
||||||
async def _enqueue_message(
|
async def _enqueue_message(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -808,6 +808,8 @@ class ProviderManager:
|
|||||||
config.save_config()
|
config.save_config()
|
||||||
# load instance
|
# load instance
|
||||||
await self.load_provider(new_config)
|
await self.load_provider(new_config)
|
||||||
|
# sync in-memory config for API queries (e.g., embedding provider list)
|
||||||
|
self.providers_config = astrbot_config["provider"]
|
||||||
|
|
||||||
async def terminate(self) -> None:
|
async def terminate(self) -> None:
|
||||||
if self._mcp_init_task and not self._mcp_init_task.done():
|
if self._mcp_init_task and not self._mcp_init_task.done():
|
||||||
|
|||||||
@@ -13,3 +13,11 @@ class ProviderGroq(ProviderOpenAIOfficial):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(provider_config, provider_settings)
|
super().__init__(provider_config, provider_settings)
|
||||||
self.reasoning_key = "reasoning"
|
self.reasoning_key = "reasoning"
|
||||||
|
|
||||||
|
def _finally_convert_payload(self, payloads: dict) -> None:
|
||||||
|
"""Groq rejects assistant history items that include reasoning_content."""
|
||||||
|
super()._finally_convert_payload(payloads)
|
||||||
|
for message in payloads.get("messages", []):
|
||||||
|
if message.get("role") == "assistant":
|
||||||
|
message.pop("reasoning_content", None)
|
||||||
|
message.pop("reasoning", None)
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
state.handle_chunk(chunk)
|
state.handle_chunk(chunk)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Saving chunk state error: " + str(e))
|
logger.warning("Saving chunk state error: " + str(e))
|
||||||
if len(chunk.choices) == 0:
|
if not chunk.choices:
|
||||||
continue
|
continue
|
||||||
delta = chunk.choices[0].delta
|
delta = chunk.choices[0].delta
|
||||||
# logger.debug(f"chunk delta: {delta}")
|
# logger.debug(f"chunk delta: {delta}")
|
||||||
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
if reasoning:
|
if reasoning:
|
||||||
llm_response.reasoning_content = reasoning
|
llm_response.reasoning_content = reasoning
|
||||||
_y = True
|
_y = True
|
||||||
if delta.content:
|
if delta and delta.content:
|
||||||
# Don't strip streaming chunks to preserve spaces between words
|
# Don't strip streaming chunks to preserve spaces between words
|
||||||
completion_text = self._normalize_content(delta.content, strip=False)
|
completion_text = self._normalize_content(delta.content, strip=False)
|
||||||
llm_response.result_chain = MessageChain(
|
llm_response.result_chain = MessageChain(
|
||||||
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
||||||
reasoning_text = ""
|
reasoning_text = ""
|
||||||
if len(completion.choices) == 0:
|
if not completion.choices:
|
||||||
return reasoning_text
|
return reasoning_text
|
||||||
if isinstance(completion, ChatCompletion):
|
if isinstance(completion, ChatCompletion):
|
||||||
choice = completion.choices[0]
|
choice = completion.choices[0]
|
||||||
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
"""Parse OpenAI ChatCompletion into LLMResponse"""
|
"""Parse OpenAI ChatCompletion into LLMResponse"""
|
||||||
llm_response = LLMResponse("assistant")
|
llm_response = LLMResponse("assistant")
|
||||||
|
|
||||||
if len(completion.choices) == 0:
|
if not completion.choices:
|
||||||
raise Exception("API 返回的 completion 为空。")
|
raise Exception("API 返回的 completion 为空。")
|
||||||
choice = completion.choices[0]
|
choice = completion.choices[0]
|
||||||
|
|
||||||
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
# 最后一次不等待
|
# 最后一次不等待
|
||||||
if retry_cnt < max_retries - 1:
|
if retry_cnt < max_retries - 1:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
available_api_keys.remove(chosen_key)
|
if chosen_key in available_api_keys:
|
||||||
|
available_api_keys.remove(chosen_key)
|
||||||
if len(available_api_keys) > 0:
|
if len(available_api_keys) > 0:
|
||||||
chosen_key = random.choice(available_api_keys)
|
chosen_key = random.choice(available_api_keys)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,4 +16,7 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
|
|||||||
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
||||||
"https://github.com/AstrBotDevs/AstrBot"
|
"https://github.com/AstrBotDevs/AstrBot"
|
||||||
)
|
)
|
||||||
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
|
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore
|
||||||
|
self.client._custom_headers["X-OpenRouter-Categories"] = (
|
||||||
|
"general-chat,personal-agent" # type: ignore
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,12 +25,22 @@ class UmopConfigRouter:
|
|||||||
)
|
)
|
||||||
self.umop_to_conf_id = sp_data
|
self.umop_to_conf_id = sp_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_umo(umo: str) -> tuple[str, str, str] | None:
|
||||||
|
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
|
||||||
|
if not isinstance(umo, str):
|
||||||
|
return None
|
||||||
|
parts = umo.split(":", 2)
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
return parts[0], parts[1], parts[2]
|
||||||
|
|
||||||
def _is_umo_match(self, p1: str, p2: str) -> bool:
|
def _is_umo_match(self, p1: str, p2: str) -> bool:
|
||||||
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
|
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
|
||||||
p1_ls = p1.split(":")
|
p1_ls = self._split_umo(p1)
|
||||||
p2_ls = p2.split(":")
|
p2_ls = self._split_umo(p2)
|
||||||
|
|
||||||
if len(p1_ls) != 3 or len(p2_ls) != 3:
|
if p1_ls is None or p2_ls is None:
|
||||||
return False # 非法格式
|
return False # 非法格式
|
||||||
|
|
||||||
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
|
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
|
||||||
@@ -62,7 +72,7 @@ class UmopConfigRouter:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
for part in new_routing:
|
for part in new_routing:
|
||||||
if not isinstance(part, str) or len(part.split(":")) != 3:
|
if self._split_umo(part) is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||||
)
|
)
|
||||||
@@ -81,7 +91,7 @@ class UmopConfigRouter:
|
|||||||
ValueError: 如果 umo 格式不正确
|
ValueError: 如果 umo 格式不正确
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
if self._split_umo(umo) is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||||
)
|
)
|
||||||
@@ -99,7 +109,7 @@ class UmopConfigRouter:
|
|||||||
ValueError: 当 umo 格式不正确时抛出
|
ValueError: 当 umo 格式不正确时抛出
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
if self._split_umo(umo) is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ class AuthRoute(Route):
|
|||||||
def generate_jwt(self, username):
|
def generate_jwt(self, username):
|
||||||
payload = {
|
payload = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
"exp": datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
+ datetime.timedelta(days=7),
|
||||||
}
|
}
|
||||||
jwt_token = self.config["dashboard"].get("jwt_secret", None)
|
jwt_token = self.config["dashboard"].get("jwt_secret", None)
|
||||||
if not jwt_token:
|
if not jwt_token:
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ async def track_conversation(convs: dict, conv_id: str):
|
|||||||
convs.pop(conv_id, None)
|
convs.pop(conv_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_webchat_stream_result(back_queue, username: str):
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return None, False
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||||
|
return None, True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebChat stream error: {e}")
|
||||||
|
return None, False
|
||||||
|
return result, False
|
||||||
|
|
||||||
|
|
||||||
class ChatRoute(Route):
|
class ChatRoute(Route):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -51,6 +65,7 @@ class ChatRoute(Route):
|
|||||||
"/chat/get_session": ("GET", self.get_session),
|
"/chat/get_session": ("GET", self.get_session),
|
||||||
"/chat/stop": ("POST", self.stop_session),
|
"/chat/stop": ("POST", self.stop_session),
|
||||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||||
|
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
|
||||||
"/chat/update_session_display_name": (
|
"/chat/update_session_display_name": (
|
||||||
"POST",
|
"POST",
|
||||||
self.update_session_display_name,
|
self.update_session_display_name,
|
||||||
@@ -342,16 +357,12 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||||
while True:
|
while True:
|
||||||
try:
|
result, should_break = await _poll_webchat_stream_result(
|
||||||
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
back_queue, username
|
||||||
except asyncio.TimeoutError:
|
)
|
||||||
continue
|
if should_break:
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
|
||||||
client_disconnected = True
|
client_disconnected = True
|
||||||
except Exception as e:
|
break
|
||||||
logger.error(f"WebChat stream error: {e}")
|
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -578,19 +589,9 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||||
|
|
||||||
async def delete_webchat_session(self):
|
async def _delete_session_internal(self, session, username: str) -> None:
|
||||||
"""Delete a Platform session and all its related data."""
|
"""Delete a single session and all its related data."""
|
||||||
session_id = request.args.get("session_id")
|
session_id = session.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 = "GroupMessage" if session.is_group else "FriendMessage"
|
message_type = "GroupMessage" if session.is_group else "FriendMessage"
|
||||||
@@ -632,8 +633,70 @@ class ChatRoute(Route):
|
|||||||
# 删除会话
|
# 删除会话
|
||||||
await self.db.delete_platform_session(session_id)
|
await self.db.delete_platform_session(session_id)
|
||||||
|
|
||||||
|
async def delete_webchat_session(self):
|
||||||
|
"""Delete a Platform session and all its related data."""
|
||||||
|
session_id = request.args.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__
|
||||||
|
|
||||||
|
await self._delete_session_internal(session, username)
|
||||||
|
|
||||||
return Response().ok().__dict__
|
return Response().ok().__dict__
|
||||||
|
|
||||||
|
async def batch_delete_sessions(self):
|
||||||
|
"""Batch delete multiple Platform sessions."""
|
||||||
|
post_data = await request.json
|
||||||
|
if post_data is None:
|
||||||
|
return Response().error("Missing JSON body").__dict__
|
||||||
|
if not isinstance(post_data, dict):
|
||||||
|
return Response().error("Invalid JSON body: expected object").__dict__
|
||||||
|
|
||||||
|
session_ids = post_data.get("session_ids")
|
||||||
|
if not session_ids or not isinstance(session_ids, list):
|
||||||
|
return Response().error("Missing or invalid key: session_ids").__dict__
|
||||||
|
|
||||||
|
username = g.get("username", "guest")
|
||||||
|
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
|
||||||
|
sessions_by_id = {session.session_id: session for session in sessions}
|
||||||
|
deleted_count = 0
|
||||||
|
failed_items = []
|
||||||
|
|
||||||
|
for sid in session_ids:
|
||||||
|
session = sessions_by_id.get(sid)
|
||||||
|
if not session:
|
||||||
|
failed_items.append({"session_id": sid, "reason": "not found"})
|
||||||
|
continue
|
||||||
|
if session.creator != username:
|
||||||
|
failed_items.append({"session_id": sid, "reason": "permission denied"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._delete_session_internal(session, username)
|
||||||
|
deleted_count += 1
|
||||||
|
sessions_by_id.pop(sid, None)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to delete session %s", sid)
|
||||||
|
failed_items.append({"session_id": sid, "reason": "internal_error"})
|
||||||
|
|
||||||
|
return (
|
||||||
|
Response()
|
||||||
|
.ok(
|
||||||
|
data={
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
"failed_count": len(failed_items),
|
||||||
|
"failed_items": failed_items,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.__dict__
|
||||||
|
)
|
||||||
|
|
||||||
def _extract_attachment_ids(self, history_list) -> list[str]:
|
def _extract_attachment_ids(self, history_list) -> list[str]:
|
||||||
"""从消息历史中提取所有 attachment_id"""
|
"""从消息历史中提取所有 attachment_id"""
|
||||||
attachment_ids = []
|
attachment_ids = []
|
||||||
|
|||||||
@@ -130,16 +130,6 @@ class LiveChatRoute(Route):
|
|||||||
|
|
||||||
async def live_chat_ws(self) -> None:
|
async def live_chat_ws(self) -> None:
|
||||||
"""Legacy Live Chat WebSocket 处理器(默认 ct=live)"""
|
"""Legacy Live Chat WebSocket 处理器(默认 ct=live)"""
|
||||||
await self._unified_ws_loop(force_ct="live")
|
|
||||||
|
|
||||||
async def unified_chat_ws(self) -> None:
|
|
||||||
"""Unified Chat WebSocket 处理器(支持 ct=live/chat)"""
|
|
||||||
await self._unified_ws_loop(force_ct=None)
|
|
||||||
|
|
||||||
async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
|
|
||||||
"""统一 WebSocket 循环"""
|
|
||||||
# WebSocket 不能通过 header 传递 token,需要从 query 参数获取
|
|
||||||
# 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
|
|
||||||
token = websocket.args.get("token")
|
token = websocket.args.get("token")
|
||||||
if not token:
|
if not token:
|
||||||
await websocket.close(1008, "Missing authentication token")
|
await websocket.close(1008, "Missing authentication token")
|
||||||
@@ -156,6 +146,49 @@ class LiveChatRoute(Route):
|
|||||||
await websocket.close(1008, "Invalid token")
|
await websocket.close(1008, "Invalid token")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
await self.run_ws_session(username=username, force_ct="live")
|
||||||
|
|
||||||
|
async def unified_chat_ws(self) -> None:
|
||||||
|
"""Unified Chat WebSocket 处理器(支持 ct=live/chat)"""
|
||||||
|
token = websocket.args.get("token")
|
||||||
|
if not token:
|
||||||
|
await websocket.close(1008, "Missing authentication token")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
jwt_secret = self.config["dashboard"].get("jwt_secret")
|
||||||
|
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||||
|
username = payload["username"]
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
await websocket.close(1008, "Token expired")
|
||||||
|
return
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
await websocket.close(1008, "Invalid token")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.run_ws_session(username=username, force_ct=None)
|
||||||
|
|
||||||
|
async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
|
||||||
|
"""统一 WebSocket 循环"""
|
||||||
|
# Keep the legacy entry point for internal call sites.
|
||||||
|
token = websocket.args.get("token")
|
||||||
|
if not token:
|
||||||
|
await websocket.close(1008, "Missing authentication token")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
jwt_secret = self.config["dashboard"].get("jwt_secret")
|
||||||
|
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||||
|
username = payload["username"]
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
await websocket.close(1008, "Token expired")
|
||||||
|
return
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
await websocket.close(1008, "Invalid token")
|
||||||
|
return
|
||||||
|
await self.run_ws_session(username=username, force_ct=force_ct)
|
||||||
|
|
||||||
|
async def run_ws_session(self, username: str, force_ct: str | None = None) -> None:
|
||||||
|
"""Run a live/unified websocket session for an authenticated username."""
|
||||||
session_id = f"webchat_live!{username}!{uuid.uuid4()}"
|
session_id = f"webchat_live!{username}!{uuid.uuid4()}"
|
||||||
live_session = LiveChatSession(session_id, username)
|
live_session = LiveChatSession(session_id, username)
|
||||||
self.sessions[session_id] = live_session
|
self.sessions[session_id] = live_session
|
||||||
@@ -690,6 +723,16 @@ class LiveChatRoute(Route):
|
|||||||
|
|
||||||
elif msg_type == "end_speaking":
|
elif msg_type == "end_speaking":
|
||||||
# 结束说话
|
# 结束说话
|
||||||
|
if session.is_processing:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"t": "error",
|
||||||
|
"data": "Session is busy",
|
||||||
|
"code": "PROCESSING_ERROR",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
stamp = message.get("stamp")
|
stamp = message.get("stamp")
|
||||||
if not stamp:
|
if not stamp:
|
||||||
logger.warning("[Live Chat] end_speaking 缺少 stamp")
|
logger.warning("[Live Chat] end_speaking 缺少 stamp")
|
||||||
@@ -703,45 +746,59 @@ class LiveChatRoute(Route):
|
|||||||
# 处理音频:STT -> LLM -> TTS
|
# 处理音频:STT -> LLM -> TTS
|
||||||
await self._process_audio(session, audio_path, assemble_duration)
|
await self._process_audio(session, audio_path, assemble_duration)
|
||||||
|
|
||||||
|
elif msg_type == "text_input":
|
||||||
|
if session.is_processing:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"t": "error",
|
||||||
|
"data": "Session is busy",
|
||||||
|
"code": "PROCESSING_ERROR",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_text = message.get("text")
|
||||||
|
if not isinstance(user_text, str):
|
||||||
|
user_text = message.get("message")
|
||||||
|
|
||||||
|
if not isinstance(user_text, str) or not user_text.strip():
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"t": "error",
|
||||||
|
"data": "message must be non-empty text",
|
||||||
|
"code": "INVALID_MESSAGE_FORMAT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._process_live_user_text(
|
||||||
|
session,
|
||||||
|
user_text=user_text.strip(),
|
||||||
|
initial_metrics={"input_type": "text"},
|
||||||
|
processing_start_time=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
elif msg_type == "interrupt":
|
elif msg_type == "interrupt":
|
||||||
# 用户打断
|
# 用户打断
|
||||||
session.should_interrupt = True
|
session.should_interrupt = True
|
||||||
logger.info(f"[Live Chat] 用户打断: {session.username}")
|
logger.info(f"[Live Chat] 用户打断: {session.username}")
|
||||||
|
|
||||||
async def _process_audio(
|
async def _process_live_user_text(
|
||||||
self, session: LiveChatSession, audio_path: str, assemble_duration: float
|
self,
|
||||||
|
session: LiveChatSession,
|
||||||
|
user_text: str,
|
||||||
|
initial_metrics: dict[str, Any] | None = None,
|
||||||
|
processing_start_time: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""处理音频:STT -> LLM -> 流式 TTS"""
|
"""处理 Live 用户文本:走 run_live_agent pipeline 并回传流式 TTS."""
|
||||||
try:
|
try:
|
||||||
# 发送 WAV 组装耗时
|
if initial_metrics:
|
||||||
await websocket.send_json(
|
await websocket.send_json({"t": "metrics", "data": initial_metrics})
|
||||||
{"t": "metrics", "data": {"wav_assemble_time": assemble_duration}}
|
|
||||||
)
|
|
||||||
wav_assembly_finish_time = time.time()
|
|
||||||
|
|
||||||
|
processing_start = processing_start_time or time.time()
|
||||||
session.is_processing = True
|
session.is_processing = True
|
||||||
session.should_interrupt = False
|
session.should_interrupt = False
|
||||||
|
|
||||||
# 1. STT - 语音转文字
|
|
||||||
ctx = self.plugin_manager.context
|
|
||||||
stt_provider = ctx.provider_manager.stt_provider_insts[0]
|
|
||||||
|
|
||||||
if not stt_provider:
|
|
||||||
logger.error("[Live Chat] STT Provider 未配置")
|
|
||||||
await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
|
|
||||||
return
|
|
||||||
|
|
||||||
await websocket.send_json(
|
|
||||||
{"t": "metrics", "data": {"stt": stt_provider.meta().type}}
|
|
||||||
)
|
|
||||||
|
|
||||||
user_text = await stt_provider.get_text(audio_path)
|
|
||||||
if not user_text:
|
|
||||||
logger.warning("[Live Chat] STT 识别结果为空")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"[Live Chat] STT 结果: {user_text}")
|
|
||||||
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"t": "user_msg",
|
"t": "user_msg",
|
||||||
@@ -761,7 +818,6 @@ class LiveChatRoute(Route):
|
|||||||
"action_type": "live", # 标记为 live mode
|
"action_type": "live", # 标记为 live mode
|
||||||
}
|
}
|
||||||
|
|
||||||
# 将消息放入队列
|
|
||||||
await queue.put((session.username, cid, payload))
|
await queue.put((session.username, cid, payload))
|
||||||
|
|
||||||
# 3. 等待响应并流式发送 TTS 音频
|
# 3. 等待响应并流式发送 TTS 音频
|
||||||
@@ -776,11 +832,9 @@ class LiveChatRoute(Route):
|
|||||||
# 用户打断,停止处理
|
# 用户打断,停止处理
|
||||||
logger.info("[Live Chat] 检测到用户打断")
|
logger.info("[Live Chat] 检测到用户打断")
|
||||||
await websocket.send_json({"t": "stop_play"})
|
await websocket.send_json({"t": "stop_play"})
|
||||||
# 保存消息并标记为被打断
|
|
||||||
await self._save_interrupted_message(
|
await self._save_interrupted_message(
|
||||||
session, user_text, bot_text
|
session, user_text, bot_text
|
||||||
)
|
)
|
||||||
# 清空队列中未处理的消息
|
|
||||||
while not back_queue.empty():
|
while not back_queue.empty():
|
||||||
try:
|
try:
|
||||||
back_queue.get_nowait()
|
back_queue.get_nowait()
|
||||||
@@ -805,6 +859,7 @@ class LiveChatRoute(Route):
|
|||||||
|
|
||||||
result_type = result.get("type")
|
result_type = result.get("type")
|
||||||
result_chain_type = result.get("chain_type")
|
result_chain_type = result.get("chain_type")
|
||||||
|
result_streaming = bool(result.get("streaming", False))
|
||||||
data = result.get("data", "")
|
data = result.get("data", "")
|
||||||
|
|
||||||
if result_chain_type == "agent_stats":
|
if result_chain_type == "agent_stats":
|
||||||
@@ -827,29 +882,41 @@ class LiveChatRoute(Route):
|
|||||||
if result_chain_type == "tts_stats":
|
if result_chain_type == "tts_stats":
|
||||||
try:
|
try:
|
||||||
stats = json.loads(data)
|
stats = json.loads(data)
|
||||||
await websocket.send_json(
|
await websocket.send_json({"t": "metrics", "data": stats})
|
||||||
{
|
|
||||||
"t": "metrics",
|
|
||||||
"data": stats,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
|
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if result_chain_type == "live_text_delta":
|
||||||
|
if data:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"t": "bot_delta_chunk",
|
||||||
|
"data": {"text": data},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if result_type == "plain":
|
if result_type == "plain":
|
||||||
# 普通文本消息
|
if (
|
||||||
|
result_streaming
|
||||||
|
and data
|
||||||
|
and result_chain_type != "reasoning"
|
||||||
|
):
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"t": "bot_delta_chunk",
|
||||||
|
"data": {"text": data},
|
||||||
|
}
|
||||||
|
)
|
||||||
bot_text += data
|
bot_text += data
|
||||||
|
|
||||||
elif result_type == "audio_chunk":
|
elif result_type == "audio_chunk":
|
||||||
# 流式音频数据
|
|
||||||
if not audio_playing:
|
if not audio_playing:
|
||||||
audio_playing = True
|
audio_playing = True
|
||||||
logger.debug("[Live Chat] 开始播放音频流")
|
logger.debug("[Live Chat] 开始播放音频流")
|
||||||
|
|
||||||
# Calculate latency from wav assembly finish to first audio chunk
|
|
||||||
speak_to_first_frame_latency = (
|
speak_to_first_frame_latency = (
|
||||||
time.time() - wav_assembly_finish_time
|
time.time() - processing_start
|
||||||
)
|
)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
@@ -869,19 +936,15 @@ class LiveChatRoute(Route):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 发送音频数据给前端
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"t": "response",
|
"t": "response",
|
||||||
"data": data, # base64 编码的音频数据
|
"data": data,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
elif result_type in ["complete", "end"]:
|
elif result_type in ["complete", "end"]:
|
||||||
# 处理完成
|
|
||||||
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
|
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
|
||||||
|
|
||||||
# 如果没有音频流,发送 bot 消息文本
|
|
||||||
if not audio_playing:
|
if not audio_playing:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
@@ -893,11 +956,8 @@ class LiveChatRoute(Route):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 发送结束标记
|
|
||||||
await websocket.send_json({"t": "end"})
|
await websocket.send_json({"t": "end"})
|
||||||
|
wav_to_tts_duration = time.time() - processing_start
|
||||||
# 发送总耗时
|
|
||||||
wav_to_tts_duration = time.time() - wav_assembly_finish_time
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"t": "metrics",
|
"t": "metrics",
|
||||||
@@ -909,13 +969,65 @@ class LiveChatRoute(Route):
|
|||||||
webchat_queue_mgr.remove_back_queue(message_id)
|
webchat_queue_mgr.remove_back_queue(message_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
|
logger.error(f"[Live Chat] 处理文本失败: {e}", exc_info=True)
|
||||||
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
|
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
session.is_processing = False
|
session.is_processing = False
|
||||||
session.should_interrupt = False
|
session.should_interrupt = False
|
||||||
|
|
||||||
|
async def _process_audio(
|
||||||
|
self, session: LiveChatSession, audio_path: str, assemble_duration: float
|
||||||
|
) -> None:
|
||||||
|
"""处理音频:STT -> LLM -> 流式 TTS"""
|
||||||
|
try:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"t": "metrics",
|
||||||
|
"data": {
|
||||||
|
"wav_assemble_time": assemble_duration,
|
||||||
|
"input_type": "audio",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
wav_assembly_finish_time = time.time()
|
||||||
|
|
||||||
|
# 1. STT - 语音转文字
|
||||||
|
ctx = self.plugin_manager.context
|
||||||
|
stt_provider = ctx.provider_manager.stt_provider_insts[0]
|
||||||
|
|
||||||
|
if not stt_provider:
|
||||||
|
logger.error("[Live Chat] STT Provider 未配置")
|
||||||
|
await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"t": "metrics",
|
||||||
|
"data": {
|
||||||
|
"stt": stt_provider.meta().type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
user_text = await stt_provider.get_text(audio_path)
|
||||||
|
if not user_text:
|
||||||
|
logger.warning("[Live Chat] STT 识别结果为空")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"[Live Chat] STT 结果: {user_text}")
|
||||||
|
|
||||||
|
await self._process_live_user_text(
|
||||||
|
session,
|
||||||
|
user_text=user_text,
|
||||||
|
initial_metrics=None,
|
||||||
|
processing_start_time=wav_assembly_finish_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
|
||||||
|
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
|
||||||
|
|
||||||
async def _save_interrupted_message(
|
async def _save_interrupted_message(
|
||||||
self, session: LiveChatSession, user_text: str, bot_text: str
|
self, session: LiveChatSession, user_text: str, bot_text: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
|||||||
|
|
||||||
from .api_key import ALL_OPEN_API_SCOPES
|
from .api_key import ALL_OPEN_API_SCOPES
|
||||||
from .chat import ChatRoute
|
from .chat import ChatRoute
|
||||||
|
from .live_chat import LiveChatRoute
|
||||||
from .route import Response, Route, RouteContext
|
from .route import Response, Route, RouteContext
|
||||||
|
|
||||||
|
|
||||||
@@ -29,12 +30,14 @@ class OpenApiRoute(Route):
|
|||||||
db: BaseDatabase,
|
db: BaseDatabase,
|
||||||
core_lifecycle: AstrBotCoreLifecycle,
|
core_lifecycle: AstrBotCoreLifecycle,
|
||||||
chat_route: ChatRoute,
|
chat_route: ChatRoute,
|
||||||
|
live_chat_route: LiveChatRoute,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(context)
|
super().__init__(context)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
self.platform_manager = core_lifecycle.platform_manager
|
self.platform_manager = core_lifecycle.platform_manager
|
||||||
self.chat_route = chat_route
|
self.chat_route = chat_route
|
||||||
|
self.live_chat_route = live_chat_route
|
||||||
|
|
||||||
self.routes = {
|
self.routes = {
|
||||||
"/v1/chat": ("POST", self.chat_send),
|
"/v1/chat": ("POST", self.chat_send),
|
||||||
@@ -46,6 +49,7 @@ class OpenApiRoute(Route):
|
|||||||
}
|
}
|
||||||
self.register_routes()
|
self.register_routes()
|
||||||
self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
|
self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
|
||||||
|
self.app.websocket("/api/v1/live/ws")(self.live_ws)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_open_username(
|
def _resolve_open_username(
|
||||||
@@ -534,6 +538,39 @@ class OpenApiRoute(Route):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Open API WS connection closed: %s", e)
|
logger.debug("Open API WS connection closed: %s", e)
|
||||||
|
|
||||||
|
async def live_ws(self) -> None:
|
||||||
|
authed, auth_err = await self._authenticate_chat_ws_api_key()
|
||||||
|
if not authed:
|
||||||
|
await self._send_chat_ws_error(auth_err or "Unauthorized", "UNAUTHORIZED")
|
||||||
|
await websocket.close(1008, auth_err or "Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
username, username_err = self._resolve_open_username(
|
||||||
|
websocket.args.get("username")
|
||||||
|
)
|
||||||
|
if username_err or not username:
|
||||||
|
await self._send_chat_ws_error(
|
||||||
|
username_err or "Invalid username",
|
||||||
|
"BAD_USER",
|
||||||
|
)
|
||||||
|
await websocket.close(1008, username_err or "Invalid username")
|
||||||
|
return
|
||||||
|
|
||||||
|
ct = websocket.args.get("ct")
|
||||||
|
force_ct = ct.strip() if isinstance(ct, str) and ct.strip() else "live"
|
||||||
|
if force_ct not in {"live", "chat"}:
|
||||||
|
await self._send_chat_ws_error(
|
||||||
|
"ct must be 'live' or 'chat'",
|
||||||
|
"INVALID_MESSAGE",
|
||||||
|
)
|
||||||
|
await websocket.close(1008, "Invalid ct")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.live_chat_route.run_ws_session(
|
||||||
|
username=username,
|
||||||
|
force_ct=force_ct,
|
||||||
|
)
|
||||||
|
|
||||||
async def upload_file(self):
|
async def upload_file(self):
|
||||||
return await self.chat_route.post_file()
|
return await self.chat_route.post_file()
|
||||||
|
|
||||||
|
|||||||
@@ -115,11 +115,13 @@ class AstrBotDashboard:
|
|||||||
self.ar = AuthRoute(self.context)
|
self.ar = AuthRoute(self.context)
|
||||||
self.api_key_route = ApiKeyRoute(self.context, db)
|
self.api_key_route = ApiKeyRoute(self.context, db)
|
||||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||||
|
self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
|
||||||
self.open_api_route = OpenApiRoute(
|
self.open_api_route = OpenApiRoute(
|
||||||
self.context,
|
self.context,
|
||||||
db,
|
db,
|
||||||
core_lifecycle,
|
core_lifecycle,
|
||||||
self.chat_route,
|
self.chat_route,
|
||||||
|
self.live_chat_route,
|
||||||
)
|
)
|
||||||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||||
@@ -138,7 +140,6 @@ class AstrBotDashboard:
|
|||||||
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
|
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
|
||||||
self.platform_route = PlatformRoute(self.context, core_lifecycle)
|
self.platform_route = PlatformRoute(self.context, core_lifecycle)
|
||||||
self.backup_route = BackupRoute(self.context, db, core_lifecycle)
|
self.backup_route = BackupRoute(self.context, db, core_lifecycle)
|
||||||
self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
|
|
||||||
|
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
"/api/plug/<path:subpath>",
|
"/api/plug/<path:subpath>",
|
||||||
@@ -244,6 +245,7 @@ class AstrBotDashboard:
|
|||||||
scope_map = {
|
scope_map = {
|
||||||
"/api/v1/chat": "chat",
|
"/api/v1/chat": "chat",
|
||||||
"/api/v1/chat/ws": "chat",
|
"/api/v1/chat/ws": "chat",
|
||||||
|
"/api/v1/live/ws": "chat",
|
||||||
"/api/v1/chat/sessions": "chat",
|
"/api/v1/chat/sessions": "chat",
|
||||||
"/api/v1/configs": "config",
|
"/api/v1/configs": "config",
|
||||||
"/api/v1/file": "file",
|
"/api/v1/file": "file",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ services:
|
|||||||
- DEFAULT_SHIP_MEMORY=512m
|
- DEFAULT_SHIP_MEMORY=512m
|
||||||
volumes:
|
volumes:
|
||||||
- ${PWD}/data/shipyard/bay_data:/app/data
|
- ${PWD}/data/shipyard/bay_data:/app/data
|
||||||
|
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
networks:
|
networks:
|
||||||
- astrbot_network
|
- astrbot_network
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"remixicon": "3.5.0",
|
"remixicon": "3.5.0",
|
||||||
"shiki": "^3.20.0",
|
"shiki": "^3.20.0",
|
||||||
"stream-markdown": "^0.0.13",
|
"stream-markdown": "^0.0.13",
|
||||||
"stream-monaco": "^0.0.17",
|
|
||||||
"vee-validate": "4.11.3",
|
"vee-validate": "4.11.3",
|
||||||
"vite-plugin-vuetify": "2.1.3",
|
"vite-plugin-vuetify": "2.1.3",
|
||||||
"vue": "3.3.4",
|
"vue": "3.3.4",
|
||||||
|
|||||||
Generated
+4
-4
@@ -81,9 +81,6 @@ importers:
|
|||||||
stream-markdown:
|
stream-markdown:
|
||||||
specifier: ^0.0.13
|
specifier: ^0.0.13
|
||||||
version: 0.0.13(shiki@3.22.0)
|
version: 0.0.13(shiki@3.22.0)
|
||||||
stream-monaco:
|
|
||||||
specifier: ^0.0.17
|
|
||||||
version: 0.0.17(monaco-editor@0.52.2)
|
|
||||||
vee-validate:
|
vee-validate:
|
||||||
specifier: 4.11.3
|
specifier: 4.11.3
|
||||||
version: 4.11.3(vue@3.3.4)
|
version: 4.11.3(vue@3.3.4)
|
||||||
@@ -3300,6 +3297,7 @@ snapshots:
|
|||||||
'@shikijs/core': 3.22.0
|
'@shikijs/core': 3.22.0
|
||||||
'@shikijs/types': 3.22.0
|
'@shikijs/types': 3.22.0
|
||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@shikijs/themes@3.22.0':
|
'@shikijs/themes@3.22.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3992,7 +3990,8 @@ snapshots:
|
|||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
alien-signals@2.0.8: {}
|
alien-signals@2.0.8:
|
||||||
|
optional: true
|
||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
@@ -5443,6 +5442,7 @@ snapshots:
|
|||||||
alien-signals: 2.0.8
|
alien-signals: 2.0.8
|
||||||
monaco-editor: 0.52.2
|
monaco-editor: 0.52.2
|
||||||
shiki: 3.22.0
|
shiki: 3.22.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
:currSessionId="currSessionId"
|
:currSessionId="currSessionId"
|
||||||
:selectedProjectId="selectedProjectId"
|
:selectedProjectId="selectedProjectId"
|
||||||
:transportMode="transportMode"
|
:transportMode="transportMode"
|
||||||
|
:sendShortcut="sendShortcut"
|
||||||
:isDark="isDark"
|
:isDark="isDark"
|
||||||
:chatboxMode="chatboxMode"
|
:chatboxMode="chatboxMode"
|
||||||
:isMobile="isMobile"
|
:isMobile="isMobile"
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
@selectConversation="handleSelectConversation"
|
@selectConversation="handleSelectConversation"
|
||||||
@editTitle="showEditTitleDialog"
|
@editTitle="showEditTitleDialog"
|
||||||
@deleteConversation="handleDeleteConversation"
|
@deleteConversation="handleDeleteConversation"
|
||||||
|
@batchDeleteConversations="handleBatchDeleteConversations"
|
||||||
@closeMobileSidebar="closeMobileSidebar"
|
@closeMobileSidebar="closeMobileSidebar"
|
||||||
@toggleTheme="toggleTheme"
|
@toggleTheme="toggleTheme"
|
||||||
@toggleFullscreen="toggleFullscreen"
|
@toggleFullscreen="toggleFullscreen"
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
@editProject="showEditProjectDialog"
|
@editProject="showEditProjectDialog"
|
||||||
@deleteProject="handleDeleteProject"
|
@deleteProject="handleDeleteProject"
|
||||||
@updateTransportMode="setTransportMode"
|
@updateTransportMode="setTransportMode"
|
||||||
|
@updateSendShortcut="setSendShortcut"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 右侧聊天内容区域 -->
|
<!-- 右侧聊天内容区域 -->
|
||||||
@@ -71,13 +74,14 @@
|
|||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="false"
|
||||||
:is-running="isStreaming || isConvRunning"
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
|
:send-shortcut="sendShortcut"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
@stop="handleStopMessage"
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@@ -102,13 +106,14 @@
|
|||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="false"
|
||||||
:is-running="isStreaming || isConvRunning"
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
|
:send-shortcut="sendShortcut"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
@stop="handleStopMessage"
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@@ -132,13 +137,14 @@
|
|||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="false"
|
||||||
:is-running="isStreaming || isConvRunning"
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
|
:send-shortcut="sendShortcut"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
@stop="handleStopMessage"
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@@ -220,10 +226,13 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
|
|||||||
import { useProjects } from '@/composables/useProjects';
|
import { useProjects } from '@/composables/useProjects';
|
||||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||||
import { useRecording } from '@/composables/useRecording';
|
import { useRecording } from '@/composables/useRecording';
|
||||||
|
import { useToast } from '@/utils/toast';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatboxMode?: boolean;
|
chatboxMode?: boolean;
|
||||||
}
|
}
|
||||||
|
type SendShortcut = 'enter' | 'shift_enter';
|
||||||
|
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
chatboxMode: false
|
chatboxMode: false
|
||||||
@@ -233,6 +242,7 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { tm } = useModuleI18n('features/chat');
|
const { tm } = useModuleI18n('features/chat');
|
||||||
|
const { warning: toastWarning } = useToast();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const customizer = useCustomizerStore();
|
const customizer = useCustomizerStore();
|
||||||
|
|
||||||
@@ -257,6 +267,7 @@ const {
|
|||||||
getSessions,
|
getSessions,
|
||||||
newSession,
|
newSession,
|
||||||
deleteSession: deleteSessionFn,
|
deleteSession: deleteSessionFn,
|
||||||
|
batchDeleteSessions,
|
||||||
showEditTitleDialog,
|
showEditTitleDialog,
|
||||||
saveTitle,
|
saveTitle,
|
||||||
updateSessionTitle,
|
updateSessionTitle,
|
||||||
@@ -330,6 +341,18 @@ interface ReplyInfo {
|
|||||||
const replyTo = ref<ReplyInfo | null>(null);
|
const replyTo = ref<ReplyInfo | null>(null);
|
||||||
|
|
||||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||||
|
const sendShortcut = ref<SendShortcut>('shift_enter');
|
||||||
|
|
||||||
|
function setSendShortcut(mode: SendShortcut) {
|
||||||
|
sendShortcut.value = mode;
|
||||||
|
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusChatInput() {
|
||||||
|
nextTick(() => {
|
||||||
|
chatInputRef.value?.focusInput?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 检测是否为手机端
|
// 检测是否为手机端
|
||||||
function checkMobile() {
|
function checkMobile() {
|
||||||
@@ -488,6 +511,7 @@ async function handleSelectConversation(sessionIds: string[]) {
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
messageList.value?.scrollToBottom();
|
messageList.value?.scrollToBottom();
|
||||||
});
|
});
|
||||||
|
focusChatInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewChat() {
|
function handleNewChat() {
|
||||||
@@ -497,6 +521,7 @@ function handleNewChat() {
|
|||||||
// 退出项目视图
|
// 退出项目视图
|
||||||
selectedProjectId.value = null;
|
selectedProjectId.value = null;
|
||||||
projectSessions.value = [];
|
projectSessions.value = [];
|
||||||
|
focusChatInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteConversation(sessionId: string) {
|
async function handleDeleteConversation(sessionId: string) {
|
||||||
@@ -510,6 +535,33 @@ async function handleDeleteConversation(sessionId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBatchDeleteConversations(sessionIds: string[]) {
|
||||||
|
try {
|
||||||
|
const result = await batchDeleteSessions(sessionIds);
|
||||||
|
|
||||||
|
// 仅在当前会话成功删除时清除信息
|
||||||
|
if (result.currentSessionDeleted) {
|
||||||
|
messages.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败处理
|
||||||
|
if (result.failed_count > 0) {
|
||||||
|
toastWarning(
|
||||||
|
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果在项目视图中,刷新项目会话列表
|
||||||
|
if (selectedProjectId.value) {
|
||||||
|
const sessions = await getProjectSessions(selectedProjectId.value);
|
||||||
|
projectSessions.value = sessions;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Batch delete sessions failed:', err);
|
||||||
|
toastWarning(tm('batch.requestFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSelectProject(projectId: string) {
|
async function handleSelectProject(projectId: string) {
|
||||||
selectedProjectId.value = projectId;
|
selectedProjectId.value = projectId;
|
||||||
const sessions = await getProjectSessions(projectId);
|
const sessions = await getProjectSessions(projectId);
|
||||||
@@ -627,6 +679,11 @@ async function handleSendMessage() {
|
|||||||
const selectedProviderId = selection?.providerId || '';
|
const selectedProviderId = selection?.providerId || '';
|
||||||
const selectedModelName = selection?.modelName || '';
|
const selectedModelName = selection?.modelName || '';
|
||||||
|
|
||||||
|
// 点击发送后立即将消息区滚到底部,确保用户看到最新消息
|
||||||
|
nextTick(() => {
|
||||||
|
messageList.value?.scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
await sendMsg(
|
await sendMsg(
|
||||||
promptToSend,
|
promptToSend,
|
||||||
filesToSend,
|
filesToSend,
|
||||||
@@ -636,6 +693,11 @@ async function handleSendMessage() {
|
|||||||
replyToSend
|
replyToSend
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 发送流程结束后再兜底一次,处理异步渲染场景
|
||||||
|
nextTick(() => {
|
||||||
|
messageList.value?.scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
// 如果在项目中创建了新会话,将其添加到项目
|
// 如果在项目中创建了新会话,将其添加到项目
|
||||||
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
|
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
|
||||||
await addSessionToProject(currSessionId.value, currentProjectId);
|
await addSessionToProject(currSessionId.value, currentProjectId);
|
||||||
@@ -694,6 +756,10 @@ watch(sessions, (newSessions) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
|
||||||
|
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
|
||||||
|
sendShortcut.value = storedShortcut;
|
||||||
|
}
|
||||||
checkMobile();
|
checkMobile();
|
||||||
window.addEventListener('resize', checkMobile);
|
window.addEventListener('resize', checkMobile);
|
||||||
getSessions();
|
getSessions();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="isDragging" class="drop-overlay">
|
<div v-if="isDragging" class="drop-overlay">
|
||||||
<div class="drop-overlay-content">
|
<div class="drop-overlay-content">
|
||||||
<v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon>
|
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
|
||||||
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
|
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<!-- Settings Menu -->
|
<!-- Settings Menu -->
|
||||||
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
||||||
<template v-slot:activator="{ props: activatorProps }">
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
|
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Upload Files -->
|
<!-- Upload Files -->
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
{{ tm('voice.liveMode') }}
|
{{ tm('voice.liveMode') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn> -->
|
</v-btn> -->
|
||||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
|
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
|
||||||
class="record-btn">
|
class="record-btn">
|
||||||
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||||
plain></v-icon>
|
plain></v-icon>
|
||||||
@@ -95,13 +95,13 @@
|
|||||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
|
<v-btn icon v-if="isRunning && !canSend" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
|
||||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||||
<v-tooltip activator="parent" location="top">
|
<v-tooltip activator="parent" location="top">
|
||||||
{{ tm('input.stopGenerating') }}
|
{{ tm('input.stopGenerating') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
|
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
|
||||||
:disabled="!canSend" class="send-btn" />
|
:disabled="!canSend" class="send-btn" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="stagedAudioUrl" class="audio-preview">
|
<div v-if="stagedAudioUrl" class="audio-preview">
|
||||||
<v-chip color="deep-purple-lighten-4" class="audio-chip">
|
<v-chip color="primary" variant="tonal" class="audio-chip">
|
||||||
<v-icon start icon="mdi-microphone" size="small"></v-icon>
|
<v-icon start icon="mdi-microphone" size="small"></v-icon>
|
||||||
{{ tm('voice.recording') }}
|
{{ tm('voice.recording') }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
|
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
|
||||||
<v-chip color="blue-grey-lighten-4" class="file-chip">
|
<v-chip color="primary" variant="tonal" class="file-chip">
|
||||||
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
|
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
|
||||||
<span class="file-name-preview">{{ file.original_name }}</span>
|
<span class="file-name-preview">{{ file.original_name }}</span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -173,6 +173,7 @@ interface Props {
|
|||||||
currentSession?: Session | null;
|
currentSession?: Session | null;
|
||||||
configId?: string | null;
|
configId?: string | null;
|
||||||
replyTo?: ReplyInfo | null;
|
replyTo?: ReplyInfo | null;
|
||||||
|
sendShortcut?: 'enter' | 'shift_enter';
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
currentSession: null,
|
currentSession: null,
|
||||||
configId: null,
|
configId: null,
|
||||||
stagedFiles: () => [],
|
stagedFiles: () => [],
|
||||||
replyTo: null
|
replyTo: null,
|
||||||
|
sendShortcut: 'shift_enter'
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
|
const isEnter = e.key === 'Enter';
|
||||||
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
|
if (!isEnter) {
|
||||||
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
|
// Ctrl+B 录音
|
||||||
|
if (e.ctrlKey && e.keyCode === 66) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (ctrlKeyDown.value) return;
|
||||||
|
|
||||||
|
ctrlKeyDown.value = true;
|
||||||
|
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||||
|
if (ctrlKeyDown.value && !props.isRecording) {
|
||||||
|
emit('startRecording');
|
||||||
|
}
|
||||||
|
}, ctrlKeyLongPressThreshold);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSendHotkey =
|
||||||
|
e.ctrlKey ||
|
||||||
|
e.metaKey ||
|
||||||
|
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
|
||||||
|
|
||||||
|
if (isSendHotkey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (localPrompt.value.trim() === '/astr_live_dev') {
|
if (localPrompt.value.trim() === '/astr_live_dev') {
|
||||||
emit('openLiveMode');
|
emit('openLiveMode');
|
||||||
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+B 录音
|
|
||||||
if (e.ctrlKey && e.keyCode === 66) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (ctrlKeyDown.value) return;
|
|
||||||
|
|
||||||
ctrlKeyDown.value = true;
|
|
||||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
|
||||||
if (ctrlKeyDown.value && !props.isRecording) {
|
|
||||||
emit('startRecording');
|
|
||||||
}
|
|
||||||
}, ctrlKeyLongPressThreshold);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyUp(e: KeyboardEvent) {
|
function handleKeyUp(e: KeyboardEvent) {
|
||||||
@@ -364,6 +373,11 @@ function getCurrentSelection() {
|
|||||||
return providerModelMenuRef.value?.getCurrentSelection();
|
return providerModelMenuRef.value?.getCurrentSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
if (!inputField.value) return;
|
||||||
|
inputField.value.focus();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (inputField.value) {
|
if (inputField.value) {
|
||||||
inputField.value.addEventListener('paste', handlePaste);
|
inputField.value.addEventListener('paste', handlePaste);
|
||||||
@@ -379,7 +393,8 @@ onBeforeUnmount(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getCurrentSelection
|
getCurrentSelection,
|
||||||
|
focusInput
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -399,8 +414,8 @@ defineExpose({
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(103, 58, 183, 0.15);
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
border: 2px dashed rgba(103, 58, 183, 0.5);
|
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -419,7 +434,7 @@ defineExpose({
|
|||||||
.drop-text {
|
.drop-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #673ab7;
|
color: rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fade transition for drop overlay */
|
/* Fade transition for drop overlay */
|
||||||
@@ -439,7 +454,7 @@ defineExpose({
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
margin: 8px 8px 0 8px;
|
margin: 8px 8px 0 8px;
|
||||||
background-color: rgba(103, 58, 183, 0.06);
|
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||||
'mobile-sidebar': isMobile
|
'mobile-sidebar': isMobile
|
||||||
}"
|
}"
|
||||||
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
|
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
|
||||||
|
|
||||||
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
||||||
@@ -21,12 +21,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding: 8px; opacity: 0.6;">
|
<div style="padding: 8px; opacity: 0.6;">
|
||||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
|
||||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||||
|
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
|
||||||
|
:color="batchMode ? 'primary' : undefined">
|
||||||
|
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Batch action bar -->
|
||||||
|
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
|
||||||
|
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
|
||||||
|
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
|
||||||
|
</v-btn>
|
||||||
|
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
|
||||||
|
@click="handleBatchDelete">
|
||||||
|
{{ tm('batch.delete') }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 项目列表组件 -->
|
<!-- 项目列表组件 -->
|
||||||
<ProjectList
|
<ProjectList
|
||||||
v-if="!sidebarCollapsed || isMobile"
|
v-if="!sidebarCollapsed || isMobile"
|
||||||
@@ -41,19 +60,34 @@
|
|||||||
v-if="!sidebarCollapsed || isMobile">
|
v-if="!sidebarCollapsed || isMobile">
|
||||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||||
<v-list density="compact" nav class="conversation-list"
|
<v-list density="compact" nav class="conversation-list"
|
||||||
style="background-color: transparent;" :selected="selectedSessions"
|
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
|
||||||
@update:selected="$emit('selectConversation', $event)">
|
@update:selected="handleListSelect">
|
||||||
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
||||||
rounded="lg" class="conversation-item" active-color="secondary">
|
rounded="lg" class="conversation-item" active-color="secondary"
|
||||||
|
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
|
||||||
|
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
|
||||||
|
<v-checkbox-btn
|
||||||
|
:model-value="batchSelected.includes(item.session_id)"
|
||||||
|
@update:model-value="toggleBatchItem(item.session_id)"
|
||||||
|
@click.stop
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="batch-checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
||||||
:style="{ color: isDark ? '#ffffff' : '#000000' }">
|
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
|
||||||
{{ item.display_name || tm('conversation.newConversation') }}
|
{{ item.display_name || tm('conversation.newConversation') }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||||
{{ new Date(item.updated_at).toLocaleString() }}
|
{{ new Date(item.updated_at).toLocaleString() }}
|
||||||
</v-list-item-subtitle> -->
|
</v-list-item-subtitle> -->
|
||||||
|
|
||||||
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
|
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
|
||||||
<div class="conversation-actions">
|
<div class="conversation-actions">
|
||||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||||
class="edit-title-btn"
|
class="edit-title-btn"
|
||||||
@@ -98,16 +132,52 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 语言切换 -->
|
<!-- 语言切换(分组) -->
|
||||||
<v-list-item class="styled-menu-item">
|
<v-menu
|
||||||
<template v-slot:prepend>
|
:open-on-hover="!isMobile"
|
||||||
<v-icon>mdi-translate</v-icon>
|
:open-on-click="isMobile"
|
||||||
|
:open-delay="!isMobile ? 60 : 0"
|
||||||
|
:close-delay="!isMobile ? 120 : 0"
|
||||||
|
:location="isMobile ? 'bottom' : 'end center'"
|
||||||
|
offset="8"
|
||||||
|
close-on-content-click
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props: languageMenuProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="languageMenuProps"
|
||||||
|
class="styled-menu-item chat-settings-group-trigger"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon>mdi-translate</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
|
||||||
|
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
|
||||||
<template v-slot:append>
|
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
|
||||||
<LanguageSwitcher variant="chatbox" />
|
<v-list density="compact" class="styled-menu-list pa-1">
|
||||||
</template>
|
<v-list-item
|
||||||
</v-list-item>
|
v-for="lang in languages"
|
||||||
|
:key="lang.code"
|
||||||
|
:value="lang.code"
|
||||||
|
@click="changeLanguage(lang.code)"
|
||||||
|
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
|
||||||
|
class="styled-menu-item"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<span class="language-flag">{{ lang.flag }}</span>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
<!-- 主题切换 -->
|
<!-- 主题切换 -->
|
||||||
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
|
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
|
||||||
@@ -117,26 +187,93 @@
|
|||||||
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
<!-- 通信传输模式 -->
|
<!-- 通信传输模式(分组) -->
|
||||||
<v-list-item class="styled-menu-item">
|
<v-menu
|
||||||
<template v-slot:prepend>
|
:open-on-hover="!isMobile"
|
||||||
<v-icon>mdi-lan-connect</v-icon>
|
:open-on-click="isMobile"
|
||||||
|
:open-delay="!isMobile ? 60 : 0"
|
||||||
|
:close-delay="!isMobile ? 120 : 0"
|
||||||
|
:location="isMobile ? 'bottom' : 'end center'"
|
||||||
|
offset="8"
|
||||||
|
close-on-content-click
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props: transportMenuProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="transportMenuProps"
|
||||||
|
class="styled-menu-item chat-settings-group-trigger"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon>mdi-lan-connect</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
|
||||||
|
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
|
||||||
<template v-slot:append>
|
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||||
<v-select
|
<v-list density="compact" class="styled-menu-list pa-1">
|
||||||
:model-value="transportMode"
|
<v-list-item
|
||||||
:items="transportOptions"
|
v-for="opt in transportOptions"
|
||||||
item-title="label"
|
:key="opt.value"
|
||||||
item-value="value"
|
:value="opt.value"
|
||||||
density="compact"
|
@click="handleTransportModeChange(opt.value)"
|
||||||
variant="underlined"
|
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
|
||||||
hide-details
|
class="styled-menu-item"
|
||||||
class="transport-mode-select"
|
rounded="md"
|
||||||
@update:model-value="handleTransportModeChange"
|
>
|
||||||
/>
|
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<!-- 发送快捷键(分组) -->
|
||||||
|
<v-menu
|
||||||
|
:open-on-hover="!isMobile"
|
||||||
|
:open-on-click="isMobile"
|
||||||
|
:open-delay="!isMobile ? 60 : 0"
|
||||||
|
:close-delay="!isMobile ? 120 : 0"
|
||||||
|
:location="isMobile ? 'bottom' : 'end center'"
|
||||||
|
offset="8"
|
||||||
|
close-on-content-click
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props: sendShortcutMenuProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="sendShortcutMenuProps"
|
||||||
|
class="styled-menu-item chat-settings-group-trigger"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon>mdi-keyboard-outline</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
|
||||||
|
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
|
||||||
|
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||||
|
<v-list density="compact" class="styled-menu-list pa-1">
|
||||||
|
<v-list-item
|
||||||
|
v-for="opt in sendShortcutOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
@click="handleSendShortcutChange(opt.value)"
|
||||||
|
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
|
||||||
|
class="styled-menu-item"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
<!-- 全屏/退出全屏 -->
|
<!-- 全屏/退出全屏 -->
|
||||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||||
@@ -162,15 +299,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||||
import type { Session } from '@/composables/useSessions';
|
import type { Session } from '@/composables/useSessions';
|
||||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
|
||||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||||
import ProjectList from '@/components/chat/ProjectList.vue';
|
import ProjectList from '@/components/chat/ProjectList.vue';
|
||||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||||
|
import { useLanguageSwitcher } from '@/i18n/composables';
|
||||||
|
import type { Locale } from '@/i18n/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
@@ -183,6 +321,7 @@ interface Props {
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
mobileMenuOpen: boolean;
|
mobileMenuOpen: boolean;
|
||||||
projects?: Project[];
|
projects?: Project[];
|
||||||
|
sendShortcut: 'enter' | 'shift_enter';
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -194,6 +333,7 @@ const emit = defineEmits<{
|
|||||||
selectConversation: [sessionIds: string[]];
|
selectConversation: [sessionIds: string[]];
|
||||||
editTitle: [sessionId: string, title: string];
|
editTitle: [sessionId: string, title: string];
|
||||||
deleteConversation: [sessionId: string];
|
deleteConversation: [sessionId: string];
|
||||||
|
batchDeleteConversations: [sessionIds: string[]];
|
||||||
closeMobileSidebar: [];
|
closeMobileSidebar: [];
|
||||||
toggleTheme: [];
|
toggleTheme: [];
|
||||||
toggleFullscreen: [];
|
toggleFullscreen: [];
|
||||||
@@ -202,6 +342,7 @@ const emit = defineEmits<{
|
|||||||
editProject: [project: Project];
|
editProject: [project: Project];
|
||||||
deleteProject: [projectId: string];
|
deleteProject: [projectId: string];
|
||||||
updateTransportMode: [mode: 'sse' | 'websocket'];
|
updateTransportMode: [mode: 'sse' | 'websocket'];
|
||||||
|
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -211,10 +352,84 @@ const confirmDialog = useConfirmDialog();
|
|||||||
|
|
||||||
const sidebarCollapsed = ref(true);
|
const sidebarCollapsed = ref(true);
|
||||||
const showProviderConfigDialog = ref(false);
|
const showProviderConfigDialog = ref(false);
|
||||||
|
|
||||||
|
// Batch mode state
|
||||||
|
const batchMode = ref(false);
|
||||||
|
const batchSelected = ref<string[]>([]);
|
||||||
|
|
||||||
|
const isAllSelected = computed(() =>
|
||||||
|
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleBatchMode() {
|
||||||
|
batchMode.value = !batchMode.value;
|
||||||
|
batchSelected.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBatchItem(sessionId: string) {
|
||||||
|
const idx = batchSelected.value.indexOf(sessionId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
batchSelected.value.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
batchSelected.value.push(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (isAllSelected.value) {
|
||||||
|
batchSelected.value = [];
|
||||||
|
} else {
|
||||||
|
batchSelected.value = props.sessions.map(s => s.session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
const count = batchSelected.value.length;
|
||||||
|
if (count === 0) return;
|
||||||
|
const message = tm('batch.confirmDelete', { count });
|
||||||
|
if (await askForConfirmation(message, confirmDialog)) {
|
||||||
|
emit('batchDeleteConversations', [...batchSelected.value]);
|
||||||
|
batchSelected.value = [];
|
||||||
|
batchMode.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleListSelect(sessionIds: string[]) {
|
||||||
|
if (!batchMode.value) {
|
||||||
|
emit('selectConversation', sessionIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
const transportOptions = [
|
const transportOptions = [
|
||||||
{ label: tm('transport.sse'), value: 'sse' as const },
|
{ label: tm('transport.sse'), value: 'sse' as const },
|
||||||
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
||||||
];
|
];
|
||||||
|
const sendShortcutOptions = [
|
||||||
|
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
|
||||||
|
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Language switcher
|
||||||
|
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
|
||||||
|
const languages = computed(() =>
|
||||||
|
languageOptions.value.map(lang => ({
|
||||||
|
code: lang.value,
|
||||||
|
name: lang.label,
|
||||||
|
flag: lang.flag
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const currentLocale = computed(() => locale.value);
|
||||||
|
const changeLanguage = async (langCode: string) => {
|
||||||
|
await switchLanguage(langCode as Locale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTransportLabel = computed(() => {
|
||||||
|
const found = transportOptions.find(opt => opt.value === props.transportMode);
|
||||||
|
return found?.label ?? '';
|
||||||
|
});
|
||||||
|
const currentSendShortcutLabel = computed(() => {
|
||||||
|
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
|
||||||
|
return found?.label ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
// 从 localStorage 读取侧边栏折叠状态
|
// 从 localStorage 读取侧边栏折叠状态
|
||||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||||
@@ -242,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
|
|||||||
emit('updateTransportMode', mode);
|
emit('updateTransportMode', mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSendShortcutChange(mode: string | null) {
|
||||||
|
if (mode === 'enter' || mode === 'shift_enter') {
|
||||||
|
emit('updateSendShortcut', mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -310,7 +531,7 @@ function handleTransportModeChange(mode: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item:hover {
|
.conversation-item:hover {
|
||||||
background-color: rgba(103, 58, 183, 0.05);
|
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item:hover .conversation-actions {
|
.conversation-item:hover .conversation-actions {
|
||||||
@@ -402,7 +623,74 @@ function handleTransportModeChange(mode: string | null) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transport-mode-select {
|
.chat-settings-group-trigger :deep(.v-list-item__append) {
|
||||||
min-width: 120px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-group-current {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-transport-current {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-group-arrow {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-flag {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-row .new-chat-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-selected-count {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-checkbox {
|
||||||
|
flex: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-checkbox-slot {
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-checkbox-slot--active {
|
||||||
|
width: 28px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -180,7 +180,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||||
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
|
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
|
||||||
import 'markstream-vue/index.css'
|
import 'markstream-vue/index.css'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import 'highlight.js/styles/github.css';
|
import 'highlight.js/styles/github.css';
|
||||||
@@ -194,8 +194,11 @@ import ActionRef from './message_list_comps/ActionRef.vue';
|
|||||||
enableKatex();
|
enableKatex();
|
||||||
enableMermaid();
|
enableMermaid();
|
||||||
|
|
||||||
// 注册自定义 ref 组件
|
// 注册 message-list 专用组件:引用节点 + Shiki 代码块渲染
|
||||||
setCustomComponents('message-list', { ref: RefNode });
|
setCustomComponents('message-list', {
|
||||||
|
ref: RefNode,
|
||||||
|
code_block: MarkdownCodeBlockNode
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MessageList',
|
name: 'MessageList',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
v-model:prompt="prompt"
|
v-model:prompt="prompt"
|
||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:disabled="isStreaming"
|
:disabled="false"
|
||||||
:is-running="isStreaming || isConvRunning"
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
|
|||||||
@@ -63,8 +63,9 @@
|
|||||||
<!-- Text (Markdown) -->
|
<!-- Text (Markdown) -->
|
||||||
<MarkdownRender
|
<MarkdownRender
|
||||||
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
||||||
|
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
|
||||||
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
|
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
|
||||||
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
|
class="markdown-content" :is-dark="isDark" />
|
||||||
|
|
||||||
<!-- Image -->
|
<!-- Image -->
|
||||||
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
|
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
|
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
|
||||||
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content"
|
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
|
||||||
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
|
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
||||||
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
|
:style="chipStyle" :href="url"
|
||||||
target="_blank" clickable>
|
target="_blank" clickable>
|
||||||
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
||||||
<span>{{ domain }}</span>
|
<span>{{ domain }}</span>
|
||||||
|
|
||||||
</v-chip>
|
</v-chip>
|
||||||
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
|
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -46,6 +46,15 @@ const domain = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const chipStyle = computed(() => ({
|
||||||
|
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
|
||||||
|
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fallbackStyle = computed(() => ({
|
||||||
|
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
size="18"
|
size="18"
|
||||||
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
|
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
|
||||||
>
|
>
|
||||||
mdi-translate
|
mdi-translate
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
||||||
import { useCustomizerStore } from '@/stores/customizer'
|
|
||||||
import type { Locale } from '@/i18n/types'
|
import type { Locale } from '@/i18n/types'
|
||||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||||
|
|
||||||
@@ -90,7 +89,7 @@ const changeLanguage = async (langCode: string) => {
|
|||||||
|
|
||||||
.language-switcher--default:hover {
|
.language-switcher--default:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
background: rgba(94, 53, 177, 0.08) !important;
|
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
|
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
|
||||||
@@ -103,8 +102,4 @@ const changeLanguage = async (langCode: string) => {
|
|||||||
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
|
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色模式下的悬停效果(仅对default变体) */
|
|
||||||
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
|
|
||||||
background: rgba(114, 46, 209, 0.12) !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -6,11 +6,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="logo-text">
|
<div class="logo-text">
|
||||||
<h2
|
<h2
|
||||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
|
:style="{ color: 'rgb(var(--v-theme-primary))' }"
|
||||||
v-html="formatTitle(title || t('core.header.logoTitle'))"
|
v-html="formatTitle(title || t('core.header.logoTitle'))"
|
||||||
></h2>
|
></h2>
|
||||||
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
||||||
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
|
<h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
|
||||||
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
|
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useCustomizerStore } from "@/stores/customizer";
|
|
||||||
import { useI18n } from '@/i18n/composables';
|
import { useI18n } from '@/i18n/composables';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
.styled-menu-card {
|
.styled-menu-card {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
|
||||||
background: #f8f6fc !important;
|
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,43 +37,41 @@ withDefaults(defineProps<{
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.styled-menu-item) {
|
.styled-menu-item {
|
||||||
margin: 2px 0;
|
margin: 2px 0;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.styled-menu-item:hover) {
|
.styled-menu-item:hover {
|
||||||
background: rgba(94, 53, 177, 0.08) !important;
|
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.styled-menu-item-active) {
|
.styled-menu-item-active {
|
||||||
background: rgba(94, 53, 177, 0.15) !important;
|
background: rgba(var(--v-theme-primary), 0.15) !important;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.styled-menu-item-active:hover) {
|
.styled-menu-item-active:hover {
|
||||||
background: rgba(94, 53, 177, 0.2) !important;
|
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
|
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
|
||||||
.v-theme--PurpleThemeDark .styled-menu-card {
|
.v-theme--PurpleThemeDark .styled-menu-card {
|
||||||
background: #2a2733 !important;
|
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色模式下的列表项悬停效果 */
|
/* 深色模式下的列表项悬停效果 */
|
||||||
.v-theme--PurpleThemeDark .styled-menu-item:hover {
|
.v-theme--PurpleThemeDark .styled-menu-item:hover {
|
||||||
background: rgba(114, 46, 209, 0.12) !important;
|
background: rgba(var(--v-theme-primary), 0.12) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-theme--PurpleThemeDark .styled-menu-item-active {
|
.v-theme--PurpleThemeDark .styled-menu-item-active {
|
||||||
background: rgba(114, 46, 209, 0.2) !important;
|
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
|
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
|
||||||
background: rgba(114, 46, 209, 0.25) !important;
|
background: rgba(var(--v-theme-primary), 0.25) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -590,9 +590,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
|||||||
async function testProvider(provider: any) {
|
async function testProvider(provider: any) {
|
||||||
testingProviders.value.push(provider.id)
|
testingProviders.value.push(provider.id)
|
||||||
try {
|
try {
|
||||||
|
const startTime = performance.now()
|
||||||
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
||||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||||
showMessage(tm('models.testSuccess', { id: provider.id }))
|
const latency = Math.max(0, Math.round(performance.now() - startTime))
|
||||||
|
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.data.data.error || tm('models.testError'))
|
throw new Error(response.data.data.error || tm('models.testError'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,73 @@ export function useSessions(chatboxMode: boolean = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchDeleteFailedItem {
|
||||||
|
session_id: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchDeleteResult {
|
||||||
|
deleted_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
failed_items: BatchDeleteFailedItem[];
|
||||||
|
currentSessionDeleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBatchDeleteResponseData(data: unknown): data is {
|
||||||
|
deleted_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
failed_items: BatchDeleteFailedItem[];
|
||||||
|
} {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload = data as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof payload.deleted_count === 'number' &&
|
||||||
|
typeof payload.failed_count === 'number' &&
|
||||||
|
Array.isArray(payload.failed_items)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
|
||||||
|
try {
|
||||||
|
const currentSessionId = currSessionId.value;
|
||||||
|
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
|
||||||
|
if (response.data?.status !== 'ok') {
|
||||||
|
throw new Error(response.data?.message || 'Failed to batch delete sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data?.data;
|
||||||
|
if (!isBatchDeleteResponseData(data)) {
|
||||||
|
throw new Error('Invalid batch delete response payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedItems = data.failed_items;
|
||||||
|
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
|
||||||
|
const currentSessionDeleted = Boolean(
|
||||||
|
currentSessionId &&
|
||||||
|
sessionIds.includes(currentSessionId) &&
|
||||||
|
!failedSessionIds.has(currentSessionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSessionDeleted) {
|
||||||
|
currSessionId.value = '';
|
||||||
|
selectedSessions.value = [];
|
||||||
|
}
|
||||||
|
await getSessions();
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted_count: data.deleted_count,
|
||||||
|
failed_count: data.failed_count,
|
||||||
|
failed_items: failedItems,
|
||||||
|
currentSessionDeleted,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showEditTitleDialog(sessionId: string, title: string) {
|
function showEditTitleDialog(sessionId: string, title: string) {
|
||||||
editingSessionId.value = sessionId;
|
editingSessionId.value = sessionId;
|
||||||
editingTitle.value = title || '';
|
editingTitle.value = title || '';
|
||||||
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
|
|||||||
getSessions,
|
getSessions,
|
||||||
newSession,
|
newSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
batchDeleteSessions,
|
||||||
showEditTitleDialog,
|
showEditTitleDialog,
|
||||||
saveTitle,
|
saveTitle,
|
||||||
updateSessionTitle,
|
updateSessionTitle,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"livePreview": "Live Preview (may differ)",
|
"livePreview": "Live Preview (may differ)",
|
||||||
"refreshPreview": "Refresh Preview",
|
"refreshPreview": "Refresh Preview",
|
||||||
|
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
|
||||||
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
|
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
|
||||||
"saveAndApply": "Save and Apply Current Template",
|
"saveAndApply": "Save and Apply Current Template",
|
||||||
"confirmReset": "Confirm Reset",
|
"confirmReset": "Confirm Reset",
|
||||||
|
|||||||
@@ -71,10 +71,16 @@
|
|||||||
"modes": {
|
"modes": {
|
||||||
"darkMode": "Switch to Dark Mode",
|
"darkMode": "Switch to Dark Mode",
|
||||||
"lightMode": "Switch to Light Mode"
|
"lightMode": "Switch to Light Mode"
|
||||||
}, "shortcuts": {
|
},
|
||||||
|
"shortcuts": {
|
||||||
"help": "Get Help",
|
"help": "Get Help",
|
||||||
"voiceRecord": "Record Voice",
|
"voiceRecord": "Record Voice",
|
||||||
"pasteImage": "Paste Image"
|
"pasteImage": "Paste Image",
|
||||||
|
"sendKey": {
|
||||||
|
"title": "Send Shortcut",
|
||||||
|
"enterToSend": "Enter to send",
|
||||||
|
"shiftEnterToSend": "Shift+Enter to send"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
"enabled": "Streaming enabled",
|
"enabled": "Streaming enabled",
|
||||||
@@ -141,5 +147,15 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"sendMessageFailed": "Failed to send message, please try again",
|
"sendMessageFailed": "Failed to send message, please try again",
|
||||||
"createSessionFailed": "Failed to create session, please refresh the page"
|
"createSessionFailed": "Failed to create session, please refresh the page"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"selected": "{count} selected",
|
||||||
|
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"delete": "Delete",
|
||||||
|
"exit": "Exit",
|
||||||
|
"partialFailure": "{failed} of {total} conversations failed to delete",
|
||||||
|
"requestFailed": "Failed to delete conversations. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
"deleteSuccess": "Model deleted successfully",
|
"deleteSuccess": "Model deleted successfully",
|
||||||
"deleteError": "Failed to delete model",
|
"deleteError": "Failed to delete model",
|
||||||
"testSuccess": "Model {id} test passed",
|
"testSuccess": "Model {id} test passed",
|
||||||
|
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
|
||||||
"testError": "Model test failed",
|
"testError": "Model test failed",
|
||||||
"searchPlaceholder": "Search models or ID",
|
"searchPlaceholder": "Search models or ID",
|
||||||
"manualAddButton": "Custom Model",
|
"manualAddButton": "Custom Model",
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"livePreview": "Предпросмотр (может отличаться)",
|
"livePreview": "Предпросмотр (может отличаться)",
|
||||||
"refreshPreview": "Обновить",
|
"refreshPreview": "Обновить",
|
||||||
|
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
|
||||||
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
|
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
|
||||||
"saveAndApply": "Сохранить и применить текущий шаблон",
|
"saveAndApply": "Сохранить и применить текущий шаблон",
|
||||||
"confirmReset": "Подтверждение сброса",
|
"confirmReset": "Подтверждение сброса",
|
||||||
|
|||||||
@@ -75,7 +75,12 @@
|
|||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"voiceRecord": "Запись голоса",
|
"voiceRecord": "Запись голоса",
|
||||||
"pasteImage": "Вставить изображение"
|
"pasteImage": "Вставить изображение",
|
||||||
|
"sendKey": {
|
||||||
|
"title": "Клавиша отправки",
|
||||||
|
"enterToSend": "Enter для отправки",
|
||||||
|
"shiftEnterToSend": "Shift+Enter для отправки"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
"enabled": "Потоковый ответ включен",
|
"enabled": "Потоковый ответ включен",
|
||||||
@@ -143,4 +148,4 @@
|
|||||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"livePreview": "实时预览(可能有差异)",
|
"livePreview": "实时预览(可能有差异)",
|
||||||
"refreshPreview": "刷新预览",
|
"refreshPreview": "刷新预览",
|
||||||
|
"previewText": "这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。",
|
||||||
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)",
|
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)",
|
||||||
"saveAndApply": "保存应用当前编辑模板",
|
"saveAndApply": "保存应用当前编辑模板",
|
||||||
"confirmReset": "确认重置",
|
"confirmReset": "确认重置",
|
||||||
|
|||||||
@@ -71,10 +71,16 @@
|
|||||||
"modes": {
|
"modes": {
|
||||||
"darkMode": "切换到夜间模式",
|
"darkMode": "切换到夜间模式",
|
||||||
"lightMode": "切换到日间模式"
|
"lightMode": "切换到日间模式"
|
||||||
}, "shortcuts": {
|
},
|
||||||
|
"shortcuts": {
|
||||||
"help": "获取帮助",
|
"help": "获取帮助",
|
||||||
"voiceRecord": "录制语音",
|
"voiceRecord": "录制语音",
|
||||||
"pasteImage": "粘贴图片"
|
"pasteImage": "粘贴图片",
|
||||||
|
"sendKey": {
|
||||||
|
"title": "发送快捷键",
|
||||||
|
"enterToSend": "Enter 发送",
|
||||||
|
"shiftEnterToSend": "Shift+Enter 发送"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
"enabled": "流式响应已开启",
|
"enabled": "流式响应已开启",
|
||||||
@@ -141,5 +147,15 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"sendMessageFailed": "发送消息失败,请重试",
|
"sendMessageFailed": "发送消息失败,请重试",
|
||||||
"createSessionFailed": "创建会话失败,请刷新页面重试"
|
"createSessionFailed": "创建会话失败,请刷新页面重试"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"selected": "已选择 {count} 个",
|
||||||
|
"confirmDelete": "确定要删除 {count} 个对话吗?此操作无法撤销。",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"deselectAll": "取消全选",
|
||||||
|
"delete": "删除",
|
||||||
|
"exit": "退出",
|
||||||
|
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
|
||||||
|
"requestFailed": "删除对话失败,请重试。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"deleteSuccess": "模型删除成功",
|
"deleteSuccess": "模型删除成功",
|
||||||
"deleteError": "模型删除失败",
|
"deleteError": "模型删除失败",
|
||||||
"testSuccess": "模型 {id} 测试通过",
|
"testSuccess": "模型 {id} 测试通过",
|
||||||
|
"testSuccessWithLatency": "模型 {id} 测试通过,延迟 {latency} ms",
|
||||||
"testError": "模型测试失败",
|
"testError": "模型测试失败",
|
||||||
"searchPlaceholder": "搜索模型或 ID",
|
"searchPlaceholder": "搜索模型或 ID",
|
||||||
"manualAddButton": "自定义模型",
|
"manualAddButton": "自定义模型",
|
||||||
|
|||||||
@@ -465,23 +465,14 @@ onMounted(async () => {
|
|||||||
<v-app-bar elevation="0" height="50" class="top-header">
|
<v-app-bar elevation="0" height="50" class="top-header">
|
||||||
|
|
||||||
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
|
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
|
||||||
<v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" style="margin-left: 16px;"
|
<v-btn v-if="customizer.viewMode === 'bot'"
|
||||||
class="hidden-md-and-down" icon rounded="sm" variant="flat"
|
style="margin-left: 16px;"
|
||||||
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
|
|
||||||
<v-icon>mdi-menu</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn v-else-if="customizer.viewMode === 'bot'"
|
|
||||||
style="margin-left: 22px;"
|
|
||||||
class="hidden-md-and-down" icon rounded="sm" variant="flat"
|
class="hidden-md-and-down" icon rounded="sm" variant="flat"
|
||||||
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
|
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
|
||||||
<v-icon>mdi-menu</v-icon>
|
<v-icon>mdi-menu</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
|
<!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
|
||||||
<v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" class="hidden-lg-and-up ms-3"
|
<v-btn v-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
|
||||||
icon rounded="sm" variant="flat" @click.stop="customizer.SET_SIDEBAR_DRAWER">
|
|
||||||
<v-icon>mdi-menu</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn v-else-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
|
|
||||||
@click.stop="customizer.SET_SIDEBAR_DRAWER">
|
@click.stop="customizer.SET_SIDEBAR_DRAWER">
|
||||||
<v-icon>mdi-menu</v-icon>
|
<v-icon>mdi-menu</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -572,21 +563,51 @@ onMounted(async () => {
|
|||||||
<v-divider class="my-1" />
|
<v-divider class="my-1" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 语言切换 -->
|
<!-- 语言切换分组 -->
|
||||||
<v-list-item
|
<v-menu
|
||||||
v-for="lang in languages"
|
:open-on-hover="!$vuetify.display.xs"
|
||||||
:key="lang.code"
|
:open-on-click="$vuetify.display.xs"
|
||||||
:value="lang.code"
|
:open-delay="!$vuetify.display.xs ? 60 : 0"
|
||||||
@click="changeLanguage(lang.code)"
|
:close-delay="!$vuetify.display.xs ? 120 : 0"
|
||||||
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
|
:location="$vuetify.display.xs ? 'bottom' : 'start center'"
|
||||||
class="styled-menu-item"
|
offset="8"
|
||||||
rounded="md"
|
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:activator="{ props: languageMenuProps }">
|
||||||
<span class="language-flag">{{ lang.flag }}</span>
|
<v-list-item
|
||||||
|
v-bind="languageMenuProps"
|
||||||
|
class="styled-menu-item language-group-trigger"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon>mdi-translate</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<span class="language-group-current">{{ currentLanguage?.flag }}</span>
|
||||||
|
<v-icon size="18" class="language-group-arrow">mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
|
||||||
|
<v-list density="compact" class="styled-menu-list pa-1">
|
||||||
|
<v-list-item
|
||||||
|
v-for="lang in languages"
|
||||||
|
:key="lang.code"
|
||||||
|
:value="lang.code"
|
||||||
|
@click="changeLanguage(lang.code)"
|
||||||
|
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
|
||||||
|
class="styled-menu-item"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<span class="language-flag">{{ lang.flag }}</span>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
<!-- 主题切换 -->
|
<!-- 主题切换 -->
|
||||||
<v-list-item
|
<v-list-item
|
||||||
@@ -978,6 +999,25 @@ onMounted(async () => {
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-group-trigger :deep(.v-list-item__append) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-group-current {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-group-arrow {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-submenu-card {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-mode-toggle-wrapper {
|
.mobile-mode-toggle-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ function openChangelogDialog() {
|
|||||||
:rail="customizer.mini_sidebar"
|
:rail="customizer.mini_sidebar"
|
||||||
>
|
>
|
||||||
<div class="sidebar-container">
|
<div class="sidebar-container">
|
||||||
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'">
|
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': customizer.mini_sidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
|
||||||
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
|
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
|
||||||
<NavItem :item="item" class="leftPadding" />
|
<NavItem :item="item" class="leftPadding" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+23
-6
@@ -11,19 +11,21 @@ import VueApexCharts from 'vue3-apexcharts';
|
|||||||
import print from 'vue3-print-nb';
|
import print from 'vue3-print-nb';
|
||||||
import { loader } from '@guolao/vue-monaco-editor'
|
import { loader } from '@guolao/vue-monaco-editor'
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs';
|
||||||
|
|
||||||
// 初始化新的i18n系统,等待完成后再挂载应用
|
// 初始化新的i18n系统,等待完成后再挂载应用
|
||||||
setupI18n().then(() => {
|
setupI18n().then(async () => {
|
||||||
console.log('🌍 新i18n系统初始化完成');
|
console.log('🌍 新i18n系统初始化完成');
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(router);
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
|
app.use(router);
|
||||||
app.use(print);
|
app.use(print);
|
||||||
app.use(VueApexCharts);
|
app.use(VueApexCharts);
|
||||||
app.use(vuetify);
|
app.use(vuetify);
|
||||||
app.use(confirmPlugin);
|
app.use(confirmPlugin);
|
||||||
|
await router.isReady();
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|
||||||
// 挂载后同步 Vuetify 主题
|
// 挂载后同步 Vuetify 主题
|
||||||
@@ -49,14 +51,15 @@ setupI18n().then(() => {
|
|||||||
|
|
||||||
// 即使i18n初始化失败,也要挂载应用(使用回退机制)
|
// 即使i18n初始化失败,也要挂载应用(使用回退机制)
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(router);
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
|
app.use(router);
|
||||||
app.use(print);
|
app.use(print);
|
||||||
app.use(VueApexCharts);
|
app.use(VueApexCharts);
|
||||||
app.use(vuetify);
|
app.use(vuetify);
|
||||||
app.use(confirmPlugin);
|
app.use(confirmPlugin);
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
waitForRouterReadyInBackground(router);
|
||||||
|
|
||||||
// 挂载后同步 Vuetify 主题
|
// 挂载后同步 Vuetify 主题
|
||||||
import('./stores/customizer').then(({ useCustomizerStore }) => {
|
import('./stores/customizer').then(({ useCustomizerStore }) => {
|
||||||
@@ -95,14 +98,28 @@ axios.interceptors.request.use((config) => {
|
|||||||
// Some parts of the UI use fetch directly; without this, those requests will 401.
|
// Some parts of the UI use fetch directly; without this, those requests will 401.
|
||||||
const _origFetch = window.fetch.bind(window);
|
const _origFetch = window.fetch.bind(window);
|
||||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const requestUrl = (() => {
|
||||||
|
if (typeof input === 'string') return input;
|
||||||
|
if (input instanceof URL) return input.toString();
|
||||||
|
return input.url;
|
||||||
|
})();
|
||||||
|
|
||||||
|
let shouldAttachAuth = false;
|
||||||
|
try {
|
||||||
|
const resolvedUrl = new URL(requestUrl, window.location.origin);
|
||||||
|
shouldAttachAuth = resolvedUrl.origin === window.location.origin;
|
||||||
|
} catch (_) {
|
||||||
|
shouldAttachAuth = requestUrl.startsWith('/');
|
||||||
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return _origFetch(input, init);
|
const locale = localStorage.getItem('astrbot-locale');
|
||||||
|
if (!token && !locale) return _origFetch(input, init);
|
||||||
|
|
||||||
const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined));
|
const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined));
|
||||||
if (!headers.has('Authorization')) {
|
if (shouldAttachAuth && token && !headers.has('Authorization')) {
|
||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
const locale = localStorage.getItem('astrbot-locale');
|
|
||||||
if (locale && !headers.has('Accept-Language')) {
|
if (locale && !headers.has('Accept-Language')) {
|
||||||
headers.set('Accept-Language', locale);
|
headers.set('Accept-Language', locale);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { EXTENSION_ROUTE_NAME } from './routeConstants.mjs';
|
||||||
|
|
||||||
const MainRoutes = {
|
const MainRoutes = {
|
||||||
path: '/main',
|
path: '/main',
|
||||||
meta: {
|
meta: {
|
||||||
@@ -17,7 +19,7 @@ const MainRoutes = {
|
|||||||
component: () => import('@/views/WelcomePage.vue')
|
component: () => import('@/views/WelcomePage.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Extensions',
|
name: EXTENSION_ROUTE_NAME,
|
||||||
path: '/extension',
|
path: '/extension',
|
||||||
component: () => import('@/views/ExtensionPage.vue')
|
component: () => import('@/views/ExtensionPage.vue')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const EXTENSION_ROUTE_NAME = 'Extensions';
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
/* 自定义滚动条样式 - 紫色主题 */
|
/* 自定义滚动条样式 - 跟随主题 */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--astrbot-scrollbar-track: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
--astrbot-scrollbar-thumb: rgba(var(--v-theme-primary), 0.72);
|
||||||
|
--astrbot-scrollbar-thumb-hover: rgba(var(--v-theme-primary), 0.84);
|
||||||
|
--astrbot-scrollbar-thumb-active: rgba(var(--v-theme-primary), 0.94);
|
||||||
|
--astrbot-scrollbar-thumb-border: rgba(var(--v-theme-surface), 0.5);
|
||||||
|
--astrbot-scrollbar-thumb-shadow: rgba(var(--v-theme-primary), 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
/* 全局滚动条样式 */
|
/* 全局滚动条样式 */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@@ -7,52 +16,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--astrbot-scrollbar-track);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(160, 60, 254, 0.75);
|
background: var(--astrbot-scrollbar-thumb);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid var(--astrbot-scrollbar-thumb-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(147, 51, 234, 0.85);
|
background: var(--astrbot-scrollbar-thumb-hover);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.3);
|
box-shadow: 0 2px 8px var(--astrbot-scrollbar-thumb-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:active {
|
::-webkit-scrollbar-thumb:active {
|
||||||
background: rgba(147, 51, 234, 0.95);
|
background: var(--astrbot-scrollbar-thumb-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
::-webkit-scrollbar-corner {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色主题滚动条样式 */
|
|
||||||
.v-theme--PurpleThemeDark {
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(192, 132, 252, 0.75);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(192, 132, 252, 0.85);
|
|
||||||
box-shadow: 0 2px 8px rgba(192, 132, 252, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:active {
|
|
||||||
background: rgba(192, 132, 252, 0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 细滚动条变体 */
|
/* 细滚动条变体 */
|
||||||
.thin-scrollbar {
|
.thin-scrollbar {
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@@ -61,17 +49,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(147, 51, 234, 0.75);
|
background: var(--astrbot-scrollbar-thumb);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-theme--PurpleThemeDark .thin-scrollbar {
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(192, 132, 252, 0.75);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 聊天区域滚动条 */
|
/* 聊天区域滚动条 */
|
||||||
.chat-scrollbar {
|
.chat-scrollbar {
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@@ -79,33 +61,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: rgba(147, 51, 234, 0.08);
|
background: var(--astrbot-scrollbar-track);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(147, 51, 234, 0.75);
|
background: var(--astrbot-scrollbar-thumb);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--astrbot-scrollbar-thumb-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(147, 51, 234, 0.85);
|
background: var(--astrbot-scrollbar-thumb-hover);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-theme--PurpleThemeDark .chat-scrollbar {
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(192, 132, 252, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(192, 132, 252, 0.75);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(192, 132, 252, 0.85);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,11 +90,7 @@
|
|||||||
/* Firefox 兼容性 */
|
/* Firefox 兼容性 */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(147, 51, 234, 0.75) rgba(0, 0, 0, 0.05);
|
scrollbar-color: var(--astrbot-scrollbar-thumb) var(--astrbot-scrollbar-track);
|
||||||
}
|
|
||||||
|
|
||||||
.v-theme--PurpleThemeDark * {
|
|
||||||
scrollbar-color: rgba(192, 132, 252, 0.75) rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 平滑滚动 */
|
/* 平滑滚动 */
|
||||||
|
|||||||
@@ -28,27 +28,27 @@
|
|||||||
.v-list-group__items .v-list-item,
|
.v-list-group__items .v-list-item,
|
||||||
.v-list-item {
|
.v-list-item {
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #b794f6 !important;
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
|
|
||||||
.v-list-item-title {
|
.v-list-item-title {
|
||||||
color: #b794f6 !important;
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-icon {
|
.v-icon {
|
||||||
color: #b794f6 !important;
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选中状态的样式
|
// 选中状态的样式
|
||||||
&.v-list-item--active {
|
&.v-list-item--active {
|
||||||
color: #b794f6 !important;
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
|
|
||||||
.v-list-item-title {
|
.v-list-item-title {
|
||||||
color: #b794f6 !important;
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-icon {
|
.v-icon {
|
||||||
color: #b794f6 !important;
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,9 +56,6 @@
|
|||||||
.v-list-item--density-default.v-list-item--one-line {
|
.v-list-item--density-default.v-list-item--one-line {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
.leftPadding {
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.v-navigation-drawer--rail {
|
.v-navigation-drawer--rail {
|
||||||
.scrollnavbar .v-list .v-list-group__items,
|
.scrollnavbar .v-list .v-list-group__items,
|
||||||
|
|||||||
@@ -4,26 +4,26 @@ const PurpleThemeDark: ThemeTypes = {
|
|||||||
name: 'PurpleThemeDark',
|
name: 'PurpleThemeDark',
|
||||||
dark: true,
|
dark: true,
|
||||||
variables: {
|
variables: {
|
||||||
'border-color': '#1677ff',
|
'border-color': '#3c96ca',
|
||||||
'carousel-control-size': 10
|
'carousel-control-size': 10
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: '#1677ff',
|
primary: '#3c96ca',
|
||||||
secondary: '#722ed1',
|
secondary: '#4ea4d8',
|
||||||
info: '#03c9d7',
|
info: '#03c9d7',
|
||||||
success: '#52c41a',
|
success: '#52c41a',
|
||||||
accent: '#FFAB91',
|
accent: '#FFAB91',
|
||||||
warning: '#faad14',
|
warning: '#faad14',
|
||||||
error: '#ff4d4f',
|
error: '#ff4d4f',
|
||||||
lightprimary: '#eef2f6',
|
lightprimary: '#e8f3fa',
|
||||||
lightsecondary: '#ede7f6',
|
lightsecondary: '#e8f3fa',
|
||||||
lightsuccess: '#b9f6ca',
|
lightsuccess: '#b9f6ca',
|
||||||
lighterror: '#f9d8d8',
|
lighterror: '#f9d8d8',
|
||||||
lightwarning: '#fff8e1',
|
lightwarning: '#fff8e1',
|
||||||
primaryText: '#ffffff',
|
primaryText: '#ffffff',
|
||||||
secondaryText: '#ffffffcc',
|
secondaryText: '#ffffffcc',
|
||||||
darkprimary: '#1565c0',
|
darkprimary: '#2f86bd',
|
||||||
darksecondary: '#4527a0',
|
darksecondary: '#2f86bd',
|
||||||
borderLight: '#d0d0d0',
|
borderLight: '#d0d0d0',
|
||||||
border: '#333333ee',
|
border: '#333333ee',
|
||||||
inputBorder: '#787878',
|
inputBorder: '#787878',
|
||||||
@@ -34,8 +34,8 @@ const PurpleThemeDark: ThemeTypes = {
|
|||||||
twitter: '#1da1f2',
|
twitter: '#1da1f2',
|
||||||
linkedin: '#0e76a8',
|
linkedin: '#0e76a8',
|
||||||
gray100: '#cccccccc',
|
gray100: '#cccccccc',
|
||||||
primary200: '#90caf9',
|
primary200: '#84c9ea',
|
||||||
secondary200: '#b39ddb',
|
secondary200: '#8cc4e1',
|
||||||
background: '#1d1d1d',
|
background: '#1d1d1d',
|
||||||
overlay: '#111111aa',
|
overlay: '#111111aa',
|
||||||
codeBg: '#282833',
|
codeBg: '#282833',
|
||||||
|
|||||||
@@ -9,21 +9,21 @@ const PurpleTheme: ThemeTypes = {
|
|||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: '#3c96ca',
|
primary: '#3c96ca',
|
||||||
secondary: '#2288b7',
|
secondary: '#2f86bd',
|
||||||
info: '#03c9d7',
|
info: '#03c9d7',
|
||||||
success: '#00c853',
|
success: '#00c853',
|
||||||
accent: '#FFAB91',
|
accent: '#FFAB91',
|
||||||
warning: '#ffc107',
|
warning: '#ffc107',
|
||||||
error: '#f44336',
|
error: '#f44336',
|
||||||
lightprimary: '#eef2f6',
|
lightprimary: '#eef2f6',
|
||||||
lightsecondary: '#ede7f6',
|
lightsecondary: '#e8f3fa',
|
||||||
lightsuccess: '#b9f6ca',
|
lightsuccess: '#b9f6ca',
|
||||||
lighterror: '#f9d8d8',
|
lighterror: '#f9d8d8',
|
||||||
lightwarning: '#fff8e1',
|
lightwarning: '#fff8e1',
|
||||||
primaryText: '#1b1c1d',
|
primaryText: '#1b1c1d',
|
||||||
secondaryText: '#000000aa',
|
secondaryText: '#000000aa',
|
||||||
darkprimary: '#1565c0',
|
darkprimary: '#1565c0',
|
||||||
darksecondary: '#4527a0',
|
darksecondary: '#236b99',
|
||||||
borderLight: '#d0d0d0',
|
borderLight: '#d0d0d0',
|
||||||
border: '#d0d0d0',
|
border: '#d0d0d0',
|
||||||
inputBorder: '#787878',
|
inputBorder: '#787878',
|
||||||
@@ -35,7 +35,7 @@ const PurpleTheme: ThemeTypes = {
|
|||||||
linkedin: '#0e76a8',
|
linkedin: '#0e76a8',
|
||||||
gray100: '#fafafacc',
|
gray100: '#fafafacc',
|
||||||
primary200: '#90caf9',
|
primary200: '#90caf9',
|
||||||
secondary200: '#b39ddb',
|
secondary200: '#8cc4e1',
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
overlay: '#ffffffaa',
|
overlay: '#ffffffaa',
|
||||||
codeBg: '#ececec',
|
codeBg: '#ececec',
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { EXTENSION_ROUTE_NAME } from '../router/routeConstants.mjs';
|
||||||
|
|
||||||
|
export function getValidHashTab(routeHash, validTabs) {
|
||||||
|
const hash = String(routeHash || '');
|
||||||
|
const tab = hash.includes('#') ? hash.slice(hash.lastIndexOf('#') + 1) : hash;
|
||||||
|
return validTabs.includes(tab) ? tab : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTabRouteLocation(route, tab, fallbackRouteName = EXTENSION_ROUTE_NAME) {
|
||||||
|
const query = route?.query ? { ...route.query } : {};
|
||||||
|
const params = route?.params ? { ...route.params } : undefined;
|
||||||
|
|
||||||
|
if (route?.name) {
|
||||||
|
return {
|
||||||
|
name: route.name,
|
||||||
|
...(params ? { params } : {}),
|
||||||
|
query,
|
||||||
|
hash: `#${tab}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route?.path) {
|
||||||
|
return {
|
||||||
|
path: route.path,
|
||||||
|
query,
|
||||||
|
hash: `#${tab}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: fallbackRouteName,
|
||||||
|
...(params ? { params } : {}),
|
||||||
|
query,
|
||||||
|
hash: `#${tab}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceTabRoute(router, route, tab, logger = console) {
|
||||||
|
try {
|
||||||
|
await router.replace(createTabRouteLocation(route, tab));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn?.('Failed to update extension tab route:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,33 +9,33 @@
|
|||||||
*/
|
*/
|
||||||
export function getProviderIcon(type) {
|
export function getProviderIcon(type) {
|
||||||
const icons = {
|
const icons = {
|
||||||
'openai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
|
'openai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openai.svg',
|
||||||
'azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg',
|
'azure': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/azure.svg',
|
||||||
'xai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg',
|
'xai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/xai.svg',
|
||||||
'anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
|
'anthropic': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/anthropic.svg',
|
||||||
'ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg',
|
'ollama': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ollama.svg',
|
||||||
'google': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
|
'google': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/gemini-color.svg',
|
||||||
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
|
'deepseek': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/deepseek.svg',
|
||||||
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg',
|
'modelscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/modelscope.svg',
|
||||||
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
|
'zhipu': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/zhipu.svg',
|
||||||
'nvidia': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/nvidia-color.svg',
|
'nvidia': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/nvidia-color.svg',
|
||||||
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
|
'siliconflow': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/siliconcloud.svg',
|
||||||
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
|
'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
|
||||||
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
|
'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg',
|
||||||
'dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg',
|
'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg',
|
||||||
"coze": "https://registry.npmmirror.com/@lobehub/icons-static-svg/1.66.0/files/icons/coze.svg",
|
"coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg",
|
||||||
'dashscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg',
|
'dashscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/alibabacloud-color.svg',
|
||||||
'deerflow': 'https://cdn.jsdelivr.net/gh/bytedance/deer-flow@main/frontend/public/images/deer.svg',
|
'deerflow': 'https://cdn.jsdelivr.net/gh/bytedance/deer-flow@main/frontend/public/images/deer.svg',
|
||||||
'fastgpt': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg',
|
'fastgpt': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fastgpt-color.svg',
|
||||||
'lm_studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg',
|
'lm_studio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/lmstudio.svg',
|
||||||
'fishaudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg',
|
'fishaudio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fishaudio.svg',
|
||||||
'minimax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg',
|
'minimax': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg',
|
||||||
'302ai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302-color.svg',
|
'302ai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.53.0/icons/ai302-color.svg',
|
||||||
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
|
'microsoft': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/microsoft.svg',
|
||||||
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
|
'vllm': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/vllm.svg',
|
||||||
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
|
'groq': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/groq.svg',
|
||||||
'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg',
|
'aihubmix': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/aihubmix-color.svg',
|
||||||
'openrouter': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg',
|
'openrouter': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openrouter.svg',
|
||||||
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
|
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
|
||||||
"compshare": "https://compshare.cn/favicon.ico"
|
"compshare": "https://compshare.cn/favicon.ico"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function waitForRouterReadyInBackground(router, logger = console) {
|
||||||
|
router.isReady().catch((error) => {
|
||||||
|
logger.warn?.('Router did not become ready after fallback mount:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -602,12 +602,15 @@ async function testSingleProvider(provider) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startTime = performance.now()
|
||||||
const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`)
|
const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`)
|
||||||
if (res.data && res.data.status === 'ok') {
|
if (res.data && res.data.status === 'ok') {
|
||||||
const index = providerStatuses.value.findIndex(s => s.id === provider.id)
|
const index = providerStatuses.value.findIndex(s => s.id === provider.id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
providerStatuses.value.splice(index, 1, res.data.data)
|
providerStatuses.value.splice(index, 1, res.data.data)
|
||||||
}
|
}
|
||||||
|
const latency = Math.max(0, Math.round(performance.now() - startTime))
|
||||||
|
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res.data?.message || `Failed to check status for ${provider.id}`)
|
throw new Error(res.data?.message || `Failed to check status for ${provider.id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ onMounted(() => {
|
|||||||
<div class="d-flex align-center gap-1">
|
<div class="d-flex align-center gap-1">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<v-divider vertical class="mx-1"
|
<v-divider vertical class="mx-1"
|
||||||
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider>
|
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(var(--v-theme-primary), 0.45) !important;"></v-divider>
|
||||||
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
|
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
|
||||||
<v-icon size="18" :color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'">
|
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
|
||||||
mdi-white-balance-sunny
|
mdi-white-balance-sunny
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-tooltip activator="parent" location="top">
|
<v-tooltip activator="parent" location="top">
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
toInitials,
|
toInitials,
|
||||||
toPinyinText,
|
toPinyinText,
|
||||||
} from "@/utils/pluginSearch";
|
} from "@/utils/pluginSearch";
|
||||||
|
import {
|
||||||
|
getValidHashTab,
|
||||||
|
replaceTabRoute,
|
||||||
|
} from "@/utils/hashRouteTabs.mjs";
|
||||||
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
|
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useDisplay } from "vuetify";
|
import { useDisplay } from "vuetify";
|
||||||
@@ -103,16 +107,11 @@ export const useExtensionPage = () => {
|
|||||||
const activeTab = ref("installed");
|
const activeTab = ref("installed");
|
||||||
const validTabs = ["installed", "market", "mcp", "skills", "components"];
|
const validTabs = ["installed", "market", "mcp", "skills", "components"];
|
||||||
const isValidTab = (tab) => validTabs.includes(tab);
|
const isValidTab = (tab) => validTabs.includes(tab);
|
||||||
const getLocationHash = () =>
|
const getLocationHash = () => route.hash || "";
|
||||||
typeof window !== "undefined" ? window.location.hash : "";
|
const extractTabFromHash = (hash) => getValidHashTab(hash, validTabs);
|
||||||
const extractTabFromHash = (hash) => {
|
|
||||||
const lastHashIndex = (hash || "").lastIndexOf("#");
|
|
||||||
if (lastHashIndex === -1) return "";
|
|
||||||
return hash.slice(lastHashIndex + 1);
|
|
||||||
};
|
|
||||||
const syncTabFromHash = (hash) => {
|
const syncTabFromHash = (hash) => {
|
||||||
const tab = extractTabFromHash(hash);
|
const tab = extractTabFromHash(hash);
|
||||||
if (isValidTab(tab)) {
|
if (tab) {
|
||||||
activeTab.value = tab;
|
activeTab.value = tab;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1436,9 +1435,7 @@ export const useExtensionPage = () => {
|
|||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!syncTabFromHash(getLocationHash())) {
|
if (!syncTabFromHash(getLocationHash())) {
|
||||||
if (typeof window !== "undefined") {
|
await replaceTabRoute(router, route, activeTab.value);
|
||||||
window.location.hash = `#${activeTab.value}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await getExtensions();
|
await getExtensions();
|
||||||
|
|
||||||
@@ -1446,17 +1443,9 @@ export const useExtensionPage = () => {
|
|||||||
loadCustomSources();
|
loadCustomSources();
|
||||||
|
|
||||||
// 检查是否有 open_config 参数
|
// 检查是否有 open_config 参数
|
||||||
let urlParams;
|
const plugin_name = Array.isArray(route.query.open_config)
|
||||||
if (window.location.hash) {
|
? route.query.open_config[0]
|
||||||
// For hash mode (#/path?param=value)
|
: route.query.open_config;
|
||||||
const hashQuery = window.location.hash.split("?")[1] || "";
|
|
||||||
urlParams = new URLSearchParams(hashQuery);
|
|
||||||
} else {
|
|
||||||
// For history mode (/path?param=value)
|
|
||||||
urlParams = new URLSearchParams(window.location.search);
|
|
||||||
}
|
|
||||||
console.log("URL Parameters:", urlParams.toString());
|
|
||||||
const plugin_name = urlParams.get("open_config");
|
|
||||||
if (plugin_name) {
|
if (plugin_name) {
|
||||||
console.log(`Opening config for plugin: ${plugin_name}`);
|
console.log(`Opening config for plugin: ${plugin_name}`);
|
||||||
openExtensionConfig(plugin_name);
|
openExtensionConfig(plugin_name);
|
||||||
@@ -1528,10 +1517,10 @@ export const useExtensionPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.hash,
|
||||||
() => {
|
(newHash) => {
|
||||||
const tab = extractTabFromHash(getLocationHash());
|
const tab = extractTabFromHash(newHash);
|
||||||
if (isValidTab(tab) && tab !== activeTab.value) {
|
if (tab && tab !== activeTab.value) {
|
||||||
activeTab.value = tab;
|
activeTab.value = tab;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1539,15 +1528,8 @@ export const useExtensionPage = () => {
|
|||||||
|
|
||||||
watch(activeTab, (newTab) => {
|
watch(activeTab, (newTab) => {
|
||||||
if (!isValidTab(newTab)) return;
|
if (!isValidTab(newTab)) return;
|
||||||
const currentTab = extractTabFromHash(getLocationHash());
|
if (route.hash === `#${newTab}`) return;
|
||||||
if (currentTab === newTab) return;
|
void replaceTabRoute(router, route, newTab);
|
||||||
const hash = getLocationHash();
|
|
||||||
const lastHashIndex = hash.lastIndexOf("#");
|
|
||||||
const nextHash =
|
|
||||||
lastHashIndex > 0 ? `${hash.slice(0, lastHashIndex)}#${newTab}` : `#${newTab}`;
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.location.hash = nextHash;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import * as hashRouteTabs from '../src/utils/hashRouteTabs.mjs';
|
||||||
|
import { EXTENSION_ROUTE_NAME } from '../src/router/routeConstants.mjs';
|
||||||
|
|
||||||
|
const { createTabRouteLocation, getValidHashTab } = hashRouteTabs;
|
||||||
|
|
||||||
|
test('getValidHashTab returns the tab name for a valid route hash', () => {
|
||||||
|
const validTabs = ['installed', 'market', 'mcp'];
|
||||||
|
|
||||||
|
assert.equal(getValidHashTab('#market', validTabs), 'market');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValidHashTab rejects empty and unknown hashes', () => {
|
||||||
|
const validTabs = ['installed', 'market', 'mcp'];
|
||||||
|
|
||||||
|
assert.equal(getValidHashTab('', validTabs), null);
|
||||||
|
assert.equal(getValidHashTab('#unknown', validTabs), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValidHashTab uses the last hash segment when multiple hashes are present', () => {
|
||||||
|
const validTabs = ['installed', 'market', 'mcp'];
|
||||||
|
|
||||||
|
assert.equal(getValidHashTab('#/extension#foo#installed', validTabs), 'installed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createTabRouteLocation preserves the current path and query', () => {
|
||||||
|
const query = { open_config: 'sample-plugin', page: '2' };
|
||||||
|
const location = createTabRouteLocation(
|
||||||
|
{
|
||||||
|
path: '/extension',
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
'market',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(location, {
|
||||||
|
path: '/extension',
|
||||||
|
query: { open_config: 'sample-plugin', page: '2' },
|
||||||
|
hash: '#market',
|
||||||
|
});
|
||||||
|
assert.notEqual(location.query, query);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createTabRouteLocation falls back to the extension route name', () => {
|
||||||
|
const location = createTabRouteLocation(undefined, 'installed');
|
||||||
|
|
||||||
|
assert.deepEqual(location, {
|
||||||
|
name: EXTENSION_ROUTE_NAME,
|
||||||
|
query: {},
|
||||||
|
hash: '#installed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createTabRouteLocation prefers route name and preserves params', () => {
|
||||||
|
const params = { pluginId: 'demo-plugin' };
|
||||||
|
const location = createTabRouteLocation(
|
||||||
|
{
|
||||||
|
name: 'ExtensionDetails',
|
||||||
|
path: '/extension/demo-plugin',
|
||||||
|
params,
|
||||||
|
query: { tab: 'details' },
|
||||||
|
},
|
||||||
|
'market',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(location, {
|
||||||
|
name: 'ExtensionDetails',
|
||||||
|
params: { pluginId: 'demo-plugin' },
|
||||||
|
query: { tab: 'details' },
|
||||||
|
hash: '#market',
|
||||||
|
});
|
||||||
|
assert.notEqual(location.params, params);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createTabRouteLocation omits params for path-based routes', () => {
|
||||||
|
const params = { pluginId: 'demo-plugin' };
|
||||||
|
const location = createTabRouteLocation(
|
||||||
|
{
|
||||||
|
path: '/extension/demo-plugin',
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
'installed',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(location, {
|
||||||
|
path: '/extension/demo-plugin',
|
||||||
|
query: {},
|
||||||
|
hash: '#installed',
|
||||||
|
});
|
||||||
|
assert.equal(location.params, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaceTabRoute catches rejected router updates', async () => {
|
||||||
|
assert.equal(typeof hashRouteTabs.replaceTabRoute, 'function');
|
||||||
|
|
||||||
|
const error = new Error('blocked');
|
||||||
|
let logged;
|
||||||
|
const router = {
|
||||||
|
replace: async () => {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const logger = {
|
||||||
|
warn: (message, cause) => {
|
||||||
|
logged = { message, cause };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await hashRouteTabs.replaceTabRoute(
|
||||||
|
router,
|
||||||
|
{ name: EXTENSION_ROUTE_NAME, query: { page: '1' } },
|
||||||
|
'installed',
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.deepEqual(logged, {
|
||||||
|
message: 'Failed to update extension tab route:',
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
test('waitForRouterReadyInBackground returns immediately and logs failures', async () => {
|
||||||
|
const module = await import('../src/utils/routerReadiness.mjs').catch(() => null);
|
||||||
|
|
||||||
|
assert.ok(module?.waitForRouterReadyInBackground);
|
||||||
|
|
||||||
|
const error = new Error('router blocked');
|
||||||
|
let warned;
|
||||||
|
const readyPromise = Promise.reject(error);
|
||||||
|
const logger = {
|
||||||
|
warn: (message, cause) => {
|
||||||
|
warned = { message, cause };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = module.waitForRouterReadyInBackground(
|
||||||
|
{ isReady: () => readyPromise },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result, undefined);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.deepEqual(warned, {
|
||||||
|
message: 'Router did not become ready after fallback mount:',
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ This documentation may not cover all features comprehensively. If you have any q
|
|||||||
|
|
||||||
### Discord
|
### Discord
|
||||||
|
|
||||||
<https://discord.gg/PxgzhmxJ>
|
<https://discord.gg/hAVk6tgV36>
|
||||||
|
|
||||||
### GitHub
|
### GitHub
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ X-API-Key: abk_xxx
|
|||||||
## Common Endpoints
|
## Common Endpoints
|
||||||
|
|
||||||
- `POST /api/v1/chat`: send chat message (SSE stream, server generates UUID when `session_id` is omitted)
|
- `POST /api/v1/chat`: send chat message (SSE stream, server generates UUID when `session_id` is omitted)
|
||||||
|
- `GET /api/v1/live/ws`: Live API WebSocket (API Key auth, requires `username` query parameter, optional `ct=live|chat`)
|
||||||
- `GET /api/v1/chat/sessions`: list sessions for a specific `username` with pagination
|
- `GET /api/v1/chat/sessions`: list sessions for a specific `username` with pagination
|
||||||
- `GET /api/v1/configs`: list available config files
|
- `GET /api/v1/configs`: list available config files
|
||||||
- `POST /api/v1/file`: upload attachment
|
- `POST /api/v1/file`: upload attachment
|
||||||
@@ -49,3 +50,7 @@ curl -N 'http://localhost:6185/api/v1/chat' \
|
|||||||
Use the interactive docs:
|
Use the interactive docs:
|
||||||
|
|
||||||
- https://docs.astrbot.app/scalar.html
|
- https://docs.astrbot.app/scalar.html
|
||||||
|
|
||||||
|
For the full Live API wire protocol, see:
|
||||||
|
|
||||||
|
- `docs/live-api/README.md`
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
# AstrBot Live API Protocol
|
||||||
|
|
||||||
|
This document describes the current WebSocket protocol for AstrBot Live API.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- Legacy JWT endpoint: `/api/live_chat/ws`
|
||||||
|
- Legacy unified JWT endpoint: `/api/unified_chat/ws`
|
||||||
|
- Open API endpoint: `/api/v1/live/ws`
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Legacy dashboard endpoints
|
||||||
|
|
||||||
|
Pass a dashboard JWT in the `token` query parameter.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://localhost:6185/api/live_chat/ws?token=<dashboard_jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Open API endpoint
|
||||||
|
|
||||||
|
Use an API key and provide `username` in the query string.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://localhost:6185/api/v1/live/ws?api_key=<api_key>&username=alice
|
||||||
|
ws://localhost:6185/api/v1/live/ws?api_key=<api_key>&username=alice&ct=chat
|
||||||
|
```
|
||||||
|
|
||||||
|
`ct` values:
|
||||||
|
|
||||||
|
- `live`: voice conversation mode
|
||||||
|
- `chat`: unified chat mode over the same WebSocket transport
|
||||||
|
|
||||||
|
The Open API endpoint reuses the `chat` API key scope.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
- Protocol: WebSocket
|
||||||
|
- Payload format: UTF-8 JSON text frames
|
||||||
|
- Audio upload format in `live` mode:
|
||||||
|
- client sends raw PCM frames encoded as Base64
|
||||||
|
- sample rate: `16000`
|
||||||
|
- channels: `1`
|
||||||
|
- sample width: `16-bit`
|
||||||
|
|
||||||
|
## Top-Level Envelope
|
||||||
|
|
||||||
|
### Client to server
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "message_type",
|
||||||
|
"...": "message specific fields"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When using the unified socket, the client can also include:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "live|chat",
|
||||||
|
"t": "message_type"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server to client
|
||||||
|
|
||||||
|
Legacy `live` mode uses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "message_type",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unified `chat` mode uses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "message_type",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Some forwarded `chat` frames may also contain `t`, `streaming`, `chain_type`, `message_id`, or `session_id`.
|
||||||
|
|
||||||
|
## Live Mode
|
||||||
|
|
||||||
|
### Client messages
|
||||||
|
|
||||||
|
#### `start_speaking`
|
||||||
|
|
||||||
|
Start a voice capture segment.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "start_speaking",
|
||||||
|
"stamp": "seg_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `speaking_part`
|
||||||
|
|
||||||
|
Send one audio frame.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "speaking_part",
|
||||||
|
"data": "<base64_pcm_bytes>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `end_speaking`
|
||||||
|
|
||||||
|
Finish the current voice capture segment.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "end_speaking",
|
||||||
|
"stamp": "seg_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `text_input`
|
||||||
|
|
||||||
|
Send a plain text input directly while using `ct=live`. The server will still route through Live mode with TTS and interrupt handling.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "text_input",
|
||||||
|
"text": "Hello, what is the weather today?"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `interrupt`
|
||||||
|
|
||||||
|
Interrupt the current model or TTS response.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "interrupt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server messages
|
||||||
|
|
||||||
|
#### `metrics`
|
||||||
|
|
||||||
|
Performance and provider metadata.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "metrics",
|
||||||
|
"data": {
|
||||||
|
"wav_assemble_time": 0.12,
|
||||||
|
"stt": "whisper_api",
|
||||||
|
"llm_ttft": 0.84,
|
||||||
|
"tts_total_time": 1.72
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `user_msg`
|
||||||
|
|
||||||
|
STT result from the uploaded audio.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "user_msg",
|
||||||
|
"data": {
|
||||||
|
"text": "Hello there",
|
||||||
|
"ts": 1710000000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `bot_delta_chunk`
|
||||||
|
|
||||||
|
Raw model text delta. This is the token or chunk level stream and is not sentence segmented.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "bot_delta_chunk",
|
||||||
|
"data": {
|
||||||
|
"text": "Hel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- This event is generated directly from the model streaming path.
|
||||||
|
- It is independent from TTS chunking.
|
||||||
|
- Consumers should append `data.text` to a local buffer.
|
||||||
|
|
||||||
|
#### `bot_text_chunk`
|
||||||
|
|
||||||
|
Text associated with the current TTS chunk. This is usually sentence or phrase segmented.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "bot_text_chunk",
|
||||||
|
"data": {
|
||||||
|
"text": "Hello there."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- This event is aligned to TTS output, not raw token streaming.
|
||||||
|
- It may be coarser than `bot_delta_chunk`.
|
||||||
|
|
||||||
|
#### `response`
|
||||||
|
|
||||||
|
One TTS audio chunk, Base64 encoded.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "response",
|
||||||
|
"data": "<base64_audio_bytes>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `bot_msg`
|
||||||
|
|
||||||
|
Final bot text when the response completed without audio streaming.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "bot_msg",
|
||||||
|
"data": {
|
||||||
|
"text": "Final reply text",
|
||||||
|
"ts": 1710000001234
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `stop_play`
|
||||||
|
|
||||||
|
Stop client-side audio playback because the response was interrupted.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "stop_play"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `end`
|
||||||
|
|
||||||
|
Marks the end of the current response turn.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "end"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `error`
|
||||||
|
|
||||||
|
Recoverable or terminal processing error.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t": "error",
|
||||||
|
"data": "error message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unified Chat Mode
|
||||||
|
|
||||||
|
Set `ct=chat` on the Open API endpoint or include `"ct": "chat"` in each client frame when using `/api/unified_chat/ws`.
|
||||||
|
|
||||||
|
### Client messages
|
||||||
|
|
||||||
|
#### `bind`
|
||||||
|
|
||||||
|
Subscribe to an existing webchat session.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "bind",
|
||||||
|
"session_id": "session_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `send`
|
||||||
|
|
||||||
|
Send a chat request.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "send",
|
||||||
|
"username": "alice",
|
||||||
|
"session_id": "session_001",
|
||||||
|
"message_id": "msg_001",
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "plain",
|
||||||
|
"text": "Please summarize this"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selected_provider": "openai_chat_completion",
|
||||||
|
"selected_model": "gpt-4.1-mini",
|
||||||
|
"enable_streaming": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`message` uses the same message-part schema as `POST /api/v1/chat`.
|
||||||
|
|
||||||
|
#### `interrupt`
|
||||||
|
|
||||||
|
Interrupt the current chat response.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "interrupt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server messages
|
||||||
|
|
||||||
|
#### `session_bound`
|
||||||
|
|
||||||
|
Acknowledges a successful `bind`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "session_bound",
|
||||||
|
"session_id": "session_001",
|
||||||
|
"message_id": "ws_sub_xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Forwarded streaming events
|
||||||
|
|
||||||
|
The server forwards the normal webchat queue payloads. Common examples:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "plain",
|
||||||
|
"data": "Hello",
|
||||||
|
"streaming": true,
|
||||||
|
"chain_type": null,
|
||||||
|
"message_id": "msg_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "image",
|
||||||
|
"data": "[IMAGE]file.jpg",
|
||||||
|
"streaming": false,
|
||||||
|
"message_id": "msg_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "agent_stats",
|
||||||
|
"data": {
|
||||||
|
"time_to_first_token": 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "message_saved",
|
||||||
|
"data": {
|
||||||
|
"id": 123,
|
||||||
|
"created_at": "2026-03-16T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "end",
|
||||||
|
"data": "",
|
||||||
|
"streaming": false,
|
||||||
|
"message_id": "msg_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Chat errors
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "error",
|
||||||
|
"code": "INVALID_MESSAGE_FORMAT",
|
||||||
|
"data": "message must be list"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Client Strategy
|
||||||
|
|
||||||
|
For `live` mode:
|
||||||
|
|
||||||
|
1. Append every `bot_delta_chunk.data.text` into a raw transcript buffer.
|
||||||
|
2. Use `bot_text_chunk` only when you need text aligned with audio playback.
|
||||||
|
3. Decode and play each `response` audio chunk in arrival order.
|
||||||
|
4. Reset per-turn buffers after `end`.
|
||||||
|
|
||||||
|
For `chat` mode:
|
||||||
|
|
||||||
|
1. Treat `plain + streaming=true` as incremental text.
|
||||||
|
2. Treat `complete` or `end` as the end of a response turn.
|
||||||
|
3. Persist `message_saved` metadata if you need server-side history IDs.
|
||||||
|
|
||||||
|
## Compatibility Notes
|
||||||
|
|
||||||
|
- `bot_text_chunk` remains sentence or phrase segmented for TTS compatibility.
|
||||||
|
- `bot_delta_chunk` is the new delta-level text event for real-time rendering.
|
||||||
|
- The legacy JWT endpoints and the new Open API endpoint share the same runtime behavior after authentication.
|
||||||
@@ -257,6 +257,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/live/ws": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Open API"
|
||||||
|
],
|
||||||
|
"summary": "Live API WebSocket",
|
||||||
|
"description": "WebSocket endpoint for Live API. Authenticate with API Key using query parameter `api_key` or header `Authorization: Bearer <api_key>`, and pass `username` as a query parameter. Use `ct=live` for voice mode or `ct=chat` for unified chat mode. See docs/live-api/README.md for the full frame-level protocol.",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyHeader": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Target username for the live session."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ct",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"live",
|
||||||
|
"chat"
|
||||||
|
],
|
||||||
|
"default": "live"
|
||||||
|
},
|
||||||
|
"description": "Session mode. `live` for voice conversation, `ct=chat` for the unified chat WebSocket."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"101": {
|
||||||
|
"description": "WebSocket protocol switch"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-websocket": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/im/message": {
|
"/api/v1/im/message": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
### Discord
|
### Discord
|
||||||
|
|
||||||
https://discord.gg/PxgzhmxJ
|
https://discord.gg/hAVk6tgV36
|
||||||
|
|
||||||
### Astrbook
|
### Astrbook
|
||||||
|
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # 只需要在第一次部署时执行,后续启动不需要执行
|
astrbot init # 只需要在第一次部署时执行,后续启动不需要执行
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -41,4 +41,4 @@ AstrBot 已经上架至雨云的预装软件列表,支持**一键安装** Astr
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
然后,内网端口填写 `6185`,点击 `创建映射规则`,这样就可以通过 `http://IP:上面设置好的外网端口` 访问 AstrBot 的管理面板了。
|
然后,内网端口填写 `6185`,点击 `创建映射规则`,这样就可以通过 `http://IP:上面设置好的外网端口` 访问 AstrBot 的管理面板了。如果无法打开,请点击`备用地址`,通过备用地址访问管理面板。
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ X-API-Key: abk_xxx
|
|||||||
调用 AstrBot 内建的 Agent 进行对话交互。支持插件调用、工具调用等能力,与 IM 端对话能力一致。
|
调用 AstrBot 内建的 Agent 进行对话交互。支持插件调用、工具调用等能力,与 IM 端对话能力一致。
|
||||||
|
|
||||||
- `POST /api/v1/chat`:发送对话消息(SSE 流式返回,不传 `session_id` 会自动创建 UUID)
|
- `POST /api/v1/chat`:发送对话消息(SSE 流式返回,不传 `session_id` 会自动创建 UUID)
|
||||||
|
- `GET /api/v1/live/ws`:Live API WebSocket(API Key 鉴权,查询参数必须包含 `username`,可选 `ct=live|chat`)
|
||||||
- `GET /api/v1/chat/sessions`:分页获取指定 `username` 的会话
|
- `GET /api/v1/chat/sessions`:分页获取指定 `username` 的会话
|
||||||
- `GET /api/v1/configs`:获取可用配置文件列表
|
- `GET /api/v1/configs`:获取可用配置文件列表
|
||||||
|
|
||||||
@@ -148,3 +149,7 @@ curl -N 'http://localhost:6185/api/v1/chat' \
|
|||||||
交互式 API 文档请查看:
|
交互式 API 文档请查看:
|
||||||
|
|
||||||
- https://docs.astrbot.app/scalar.html
|
- https://docs.astrbot.app/scalar.html
|
||||||
|
|
||||||
|
Live API 协议说明请查看:
|
||||||
|
|
||||||
|
- `docs/live-api/README.md`
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
进入 AstrBot WebUI 的插件市场,搜索 `astrbot_plugin_matrix_adapter`,点击安装。
|
进入 AstrBot WebUI 的插件市场,搜索 `astrbot_plugin_matrix_adapter`,点击安装。
|
||||||
|
|
||||||
安装完成后,前往 消息平台 → 新增适配器 → 选择 Matrix(若选项缺失,尝试重启 AstrBot 或检查插件安装状态)。
|
安装完成后,前往 机器人(旧版本为 `消息平台`) → 新增适配器 → 选择 Matrix(若选项缺失,尝试重启 AstrBot 或检查插件安装状态)。
|
||||||
|
|
||||||
在弹出的配置对话框中点击 `启用`。
|
在弹出的配置对话框中点击 `启用`。
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## 配置 AstrBot
|
## 配置 AstrBot
|
||||||
|
|
||||||
1. 进入 AstrBot 的管理面板,点击左侧栏 `消息平台`,然后在右侧的界面中,点击 `+ 新增适配器`,选择 `企业微信智能机器人`,进入配置页面。
|
1. 进入 AstrBot 的管理面板,点击左侧栏 `机器人`(旧版本为 `消息平台`),然后在右侧的界面中,点击 `+ 新增适配器`,选择 `企业微信智能机器人`,进入配置页面。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
|||||||
|
|
||||||
- 部署 AstrBot:阅读部署指南,快速在本地机器或云服务器上部署 AstrBot。
|
- 部署 AstrBot:阅读部署指南,快速在本地机器或云服务器上部署 AstrBot。
|
||||||
- 连接 IM 平台:按照说明将 AstrBot 连接到您喜欢的 IM 平台,如 Discord、Telegram、Slack 等。
|
- 连接 IM 平台:按照说明将 AstrBot 连接到您喜欢的 IM 平台,如 Discord、Telegram、Slack 等。
|
||||||
- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/config/providers/start)
|
- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/providers/start)
|
||||||
|
|
||||||
## 它是如何实现的?
|
## 它是如何实现的?
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/live/ws": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Open API"
|
||||||
|
],
|
||||||
|
"summary": "Live API WebSocket",
|
||||||
|
"description": "WebSocket endpoint for Live API. Authenticate with API Key using query parameter `api_key` or header `Authorization: Bearer <api_key>`, and pass `username` as a query parameter. Use `ct=live` for voice mode or `ct=chat` for unified chat mode. See docs/live-api/README.md for the full frame-level protocol.",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyHeader": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Target username for the live session."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ct",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"live",
|
||||||
|
"chat"
|
||||||
|
],
|
||||||
|
"default": "live"
|
||||||
|
},
|
||||||
|
"description": "Session mode. `live` for voice conversation, `chat` for the unified chat WebSocket."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"101": {
|
||||||
|
"description": "WebSocket protocol switch"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-websocket": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/im/message": {
|
"/api/v1/im/message": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
aiocqhttp>=1.4.4
|
aiocqhttp>=1.4.4
|
||||||
aiodocker>=0.24.0
|
aiodocker>=0.24.0
|
||||||
aiohttp>=3.11.18
|
aiohttp>=3.11.18
|
||||||
aiocqhttp>=1.4.4
|
|
||||||
aiodocker>=0.24.0
|
|
||||||
aiohttp>=3.11.18
|
|
||||||
aiosqlite>=0.21.0
|
aiosqlite>=0.21.0
|
||||||
anthropic>=0.51.0
|
anthropic>=0.51.0
|
||||||
apscheduler>=3.11.0
|
apscheduler>=3.11.0
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
# user service
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=AstrBot Service
|
Description=AstrBot Service
|
||||||
|
Documentation=https://github.com/AstrBotDevs/AstrBot
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
@@ -9,6 +11,9 @@ WorkingDirectory=%h/.local/share/astrbot
|
|||||||
ExecStart=/usr/bin/sh -c '/usr/bin/astrbot run || { /usr/bin/astrbot init && /usr/bin/astrbot run; }'
|
ExecStart=/usr/bin/sh -c '/usr/bin/astrbot run || { /usr/bin/astrbot init && /usr/bin/astrbot run; }'
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=astrbot-%u
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Auto-generate changelog from git commits using LLM.
|
|
||||||
Usage: python scripts/generate_changelog.py [--version VERSION]
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_tag():
|
|
||||||
"""Get the latest git tag."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "describe", "--tags", "--abbrev=0"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def get_commits_since_tag(tag):
|
|
||||||
"""Get all commit messages since the specified tag."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "log", f"{tag}..HEAD", "--pretty=format:%H|%s|%b"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
commits = []
|
|
||||||
for line in result.stdout.strip().split("\n"):
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
parts = line.split("|", 2)
|
|
||||||
if len(parts) >= 2:
|
|
||||||
commit_hash = parts[0]
|
|
||||||
subject = parts[1]
|
|
||||||
body = parts[2] if len(parts) > 2 else ""
|
|
||||||
commits.append({"hash": commit_hash[:7], "subject": subject, "body": body})
|
|
||||||
return commits
|
|
||||||
|
|
||||||
|
|
||||||
def extract_issue_number(text):
|
|
||||||
"""Extract issue number from commit message."""
|
|
||||||
# Match #1234 or (#1234)
|
|
||||||
match = re.search(r"#(\d+)", text)
|
|
||||||
return match.group(1) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def call_llm_for_changelog(commits, version):
|
|
||||||
"""Call LLM to generate changelog from commits."""
|
|
||||||
try:
|
|
||||||
# Try to use OpenAI API or other LLM providers
|
|
||||||
import openai
|
|
||||||
|
|
||||||
# Build prompt
|
|
||||||
commits_text = "\n".join([f"- {c['subject']}" for c in commits])
|
|
||||||
|
|
||||||
prompt = f"""Based on the following git commit messages, generate a changelog document in BOTH Chinese and English.
|
|
||||||
|
|
||||||
Commit messages:
|
|
||||||
{commits_text}
|
|
||||||
|
|
||||||
Please organize the changes into these categories:
|
|
||||||
- 新增 (New Features)
|
|
||||||
- 修复 (Bug Fixes)
|
|
||||||
- 优化 (Improvements)
|
|
||||||
- 其他 (Others)
|
|
||||||
|
|
||||||
Format requirements:
|
|
||||||
1. Start with Chinese version under "## What's Changed"
|
|
||||||
2. Follow with English version under "## What's Changed (EN)"
|
|
||||||
3. Use markdown format with proper bullet points
|
|
||||||
4. Keep descriptions concise and user-friendly
|
|
||||||
5. If a commit mentions an issue number (#1234), include it in the format ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
|
|
||||||
|
|
||||||
Example format:
|
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
- 支持某某功能 ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
|
|
||||||
|
|
||||||
### 修复
|
|
||||||
- 修复某某问题
|
|
||||||
|
|
||||||
## What's Changed (EN)
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
- Add support for something ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- Fix something
|
|
||||||
"""
|
|
||||||
|
|
||||||
client = openai.OpenAI(
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"),
|
|
||||||
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.chat.completions.create(
|
|
||||||
model=os.getenv("OPENAI_MODEL", "gpt-4"),
|
|
||||||
messages=[
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful assistant that generates well-structured changelogs.",
|
|
||||||
},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
],
|
|
||||||
temperature=0.3,
|
|
||||||
)
|
|
||||||
|
|
||||||
return response.choices[0].message.content
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
print(
|
|
||||||
"Warning: openai package not installed. Install it with: pip install openai"
|
|
||||||
)
|
|
||||||
return generate_simple_changelog(commits)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Failed to call LLM API: {e}")
|
|
||||||
print("Falling back to simple changelog generation...")
|
|
||||||
return generate_simple_changelog(commits)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_simple_changelog(commits):
|
|
||||||
"""Generate a simple changelog without LLM."""
|
|
||||||
sections = {
|
|
||||||
"feat": ("新增", "New Features", []),
|
|
||||||
"fix": ("修复", "Bug Fixes", []),
|
|
||||||
"perf": ("优化", "Improvements", []),
|
|
||||||
"docs": ("文档", "Documentation", []),
|
|
||||||
"refactor": ("重构", "Refactoring", []),
|
|
||||||
"test": ("测试", "Tests", []),
|
|
||||||
"chore": ("其他", "Chore", []),
|
|
||||||
"other": ("其他", "Others", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Categorize commits by conventional commit type
|
|
||||||
for commit in commits:
|
|
||||||
subject = commit["subject"]
|
|
||||||
issue_num = extract_issue_number(subject)
|
|
||||||
issue_link = (
|
|
||||||
f" ([#{issue_num}](https://github.com/AstrBotDevs/AstrBot/issues/{issue_num}))"
|
|
||||||
if issue_num
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Detect conventional commit type
|
|
||||||
matched = False
|
|
||||||
for prefix in ["feat", "fix", "perf", "docs", "refactor", "test", "chore"]:
|
|
||||||
if subject.lower().startswith(f"{prefix}:") or subject.lower().startswith(
|
|
||||||
f"{prefix}("
|
|
||||||
):
|
|
||||||
# Remove prefix for display
|
|
||||||
clean_subject = re.sub(
|
|
||||||
r"^[a-z]+(\([^)]+\))?:\s*", "", subject, flags=re.IGNORECASE
|
|
||||||
)
|
|
||||||
sections[prefix][2].append(f"- {clean_subject}{issue_link}")
|
|
||||||
matched = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not matched:
|
|
||||||
sections["other"][2].append(f"- {subject}{issue_link}")
|
|
||||||
|
|
||||||
# Build Chinese version
|
|
||||||
changelog_zh = "## What's Changed\n\n"
|
|
||||||
for section_key in ["feat", "fix", "perf", "docs", "refactor", "test", "other"]:
|
|
||||||
zh_title, _, items = sections[section_key]
|
|
||||||
if items:
|
|
||||||
changelog_zh += f"### {zh_title}\n\n"
|
|
||||||
changelog_zh += "\n".join(items) + "\n\n"
|
|
||||||
|
|
||||||
# Build English version
|
|
||||||
changelog_en = "## What's Changed (EN)\n\n"
|
|
||||||
for section_key in ["feat", "fix", "perf", "docs", "refactor", "test", "other"]:
|
|
||||||
_, en_title, items = sections[section_key]
|
|
||||||
if items:
|
|
||||||
changelog_en += f"### {en_title}\n\n"
|
|
||||||
changelog_en += "\n".join(items) + "\n\n"
|
|
||||||
|
|
||||||
return changelog_zh + changelog_en
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="Generate changelog from git commits")
|
|
||||||
parser.add_argument(
|
|
||||||
"--version", help="Version number for the changelog (e.g., v4.13.3)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--use-llm",
|
|
||||||
action="store_true",
|
|
||||||
help="Use LLM to generate changelog (requires OpenAI API key)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Get latest tag
|
|
||||||
try:
|
|
||||||
latest_tag = get_latest_tag()
|
|
||||||
print(f"Latest tag: {latest_tag}")
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
print("Error: No tags found in repository")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Get commits since tag
|
|
||||||
commits = get_commits_since_tag(latest_tag)
|
|
||||||
if not commits:
|
|
||||||
print(f"No commits found since {latest_tag}")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
print(f"Found {len(commits)} commits since {latest_tag}")
|
|
||||||
|
|
||||||
# Determine version
|
|
||||||
if args.version:
|
|
||||||
version = args.version
|
|
||||||
else:
|
|
||||||
# Auto-increment patch version
|
|
||||||
match = re.match(r"v(\d+)\.(\d+)\.(\d+)", latest_tag)
|
|
||||||
if match:
|
|
||||||
major, minor, patch = map(int, match.groups())
|
|
||||||
version = f"v{major}.{minor}.{patch + 1}"
|
|
||||||
else:
|
|
||||||
print(f"Warning: Could not parse version from tag {latest_tag}")
|
|
||||||
version = "vX.X.X"
|
|
||||||
|
|
||||||
print(f"Generating changelog for {version}...")
|
|
||||||
|
|
||||||
# Generate changelog
|
|
||||||
if args.use_llm:
|
|
||||||
changelog_content = call_llm_for_changelog(commits, version)
|
|
||||||
else:
|
|
||||||
changelog_content = generate_simple_changelog(commits)
|
|
||||||
|
|
||||||
# Save to file
|
|
||||||
changelog_dir = Path(__file__).parent.parent / "changelogs"
|
|
||||||
changelog_dir.mkdir(exist_ok=True)
|
|
||||||
changelog_file = changelog_dir / f"{version}.md"
|
|
||||||
|
|
||||||
with open(changelog_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write(changelog_content)
|
|
||||||
|
|
||||||
print(f"\n✓ Changelog generated: {changelog_file}")
|
|
||||||
print("\nPreview:")
|
|
||||||
print("=" * 80)
|
|
||||||
print(changelog_content)
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from astrbot.dashboard.routes.chat import _poll_webchat_stream_result
|
||||||
|
|
||||||
|
|
||||||
|
class _QueueThatRaises:
|
||||||
|
def __init__(self, exc: BaseException):
|
||||||
|
self._exc = exc
|
||||||
|
|
||||||
|
async def get(self):
|
||||||
|
raise self._exc
|
||||||
|
|
||||||
|
|
||||||
|
class _QueueWithResult:
|
||||||
|
def __init__(self, result):
|
||||||
|
self._result = result
|
||||||
|
|
||||||
|
async def get(self):
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_webchat_stream_result_breaks_on_cancelled_error():
|
||||||
|
result, should_break = await _poll_webchat_stream_result(
|
||||||
|
_QueueThatRaises(asyncio.CancelledError()),
|
||||||
|
"alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert should_break is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_webchat_stream_result_continues_on_generic_exception():
|
||||||
|
result, should_break = await _poll_webchat_stream_result(
|
||||||
|
_QueueThatRaises(RuntimeError("boom")),
|
||||||
|
"alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert should_break is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_webchat_stream_result_returns_queue_payload():
|
||||||
|
payload = {"type": "end", "data": ""}
|
||||||
|
|
||||||
|
result, should_break = await _poll_webchat_stream_result(
|
||||||
|
_QueueWithResult(payload),
|
||||||
|
"alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == payload
|
||||||
|
assert should_break is False
|
||||||
@@ -106,6 +106,109 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
|
|||||||
assert data["status"] == "ok" and "platform" in data["data"]
|
assert data["status"] == "ok" and "platform" in data["data"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("payload", [[], "x"])
|
||||||
|
async def test_batch_delete_sessions_rejects_non_object_payload(
|
||||||
|
app: Quart, authenticated_header: dict, payload
|
||||||
|
):
|
||||||
|
test_client = app.test_client()
|
||||||
|
response = await test_client.post(
|
||||||
|
"/api/chat/batch_delete_sessions",
|
||||||
|
json=payload,
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data["status"] == "error"
|
||||||
|
assert data["message"] == "Invalid JSON body: expected object"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_delete_sessions_masks_internal_error(
|
||||||
|
app: Quart, authenticated_header: dict, monkeypatch
|
||||||
|
):
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
create_session_response = await test_client.get(
|
||||||
|
"/api/chat/new_session", headers=authenticated_header
|
||||||
|
)
|
||||||
|
assert create_session_response.status_code == 200
|
||||||
|
create_session_data = await create_session_response.get_json()
|
||||||
|
session_id = create_session_data["data"]["session_id"]
|
||||||
|
|
||||||
|
async def _raise_error(*args, **kwargs):
|
||||||
|
raise RuntimeError("secret-internal-error")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.dashboard.routes.chat.ChatRoute._delete_session_internal",
|
||||||
|
_raise_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await test_client.post(
|
||||||
|
"/api/chat/batch_delete_sessions",
|
||||||
|
json={"session_ids": [session_id]},
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["data"]["deleted_count"] == 0
|
||||||
|
assert data["data"]["failed_count"] == 1
|
||||||
|
assert data["data"]["failed_items"][0]["session_id"] == session_id
|
||||||
|
assert data["data"]["failed_items"][0]["reason"] == "internal_error"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_delete_sessions_uses_batch_lookup(
|
||||||
|
app: Quart,
|
||||||
|
authenticated_header: dict,
|
||||||
|
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
test_client = app.test_client()
|
||||||
|
db = core_lifecycle_td.db
|
||||||
|
|
||||||
|
create_session_response = await test_client.get(
|
||||||
|
"/api/chat/new_session", headers=authenticated_header
|
||||||
|
)
|
||||||
|
assert create_session_response.status_code == 200
|
||||||
|
create_session_data = await create_session_response.get_json()
|
||||||
|
session_id = create_session_data["data"]["session_id"]
|
||||||
|
|
||||||
|
original_batch_lookup = db.get_platform_sessions_by_ids
|
||||||
|
called = {"batch_lookup_count": 0}
|
||||||
|
|
||||||
|
async def _wrapped_batch_lookup(session_ids: list[str]):
|
||||||
|
called["batch_lookup_count"] += 1
|
||||||
|
return await original_batch_lookup(session_ids)
|
||||||
|
|
||||||
|
# 不应单个查询
|
||||||
|
async def _should_not_call_single_lookup(session_id: str):
|
||||||
|
raise AssertionError(
|
||||||
|
f"single-session lookup should not be called: {session_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(db, "get_platform_sessions_by_ids", _wrapped_batch_lookup)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
db, "get_platform_session_by_id", _should_not_call_single_lookup
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await test_client.post(
|
||||||
|
"/api/chat/batch_delete_sessions",
|
||||||
|
json={"session_ids": [session_id]},
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["data"]["deleted_count"] == 1
|
||||||
|
assert data["data"]["failed_count"] == 0
|
||||||
|
assert called["batch_lookup_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_plugins(
|
async def test_plugins(
|
||||||
app: Quart,
|
app: Quart,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from astrbot.core.provider.sources.groq_source import ProviderGroq
|
||||||
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
|
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +33,21 @@ def _make_provider(overrides: dict | None = None) -> ProviderOpenAIOfficial:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq:
|
||||||
|
provider_config = {
|
||||||
|
"id": "test-groq",
|
||||||
|
"type": "groq_chat_completion",
|
||||||
|
"model": "qwen/qwen3-32b",
|
||||||
|
"key": ["test-key"],
|
||||||
|
}
|
||||||
|
if overrides:
|
||||||
|
provider_config.update(overrides)
|
||||||
|
return ProviderGroq(
|
||||||
|
provider_config=provider_config,
|
||||||
|
provider_settings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_api_error_content_moderated_removes_images():
|
async def test_handle_api_error_content_moderated_removes_images():
|
||||||
provider = _make_provider(
|
provider = _make_provider(
|
||||||
@@ -198,6 +214,57 @@ def test_extract_error_text_candidates_truncates_long_response_text():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_payload_keeps_reasoning_content_in_assistant_history():
|
||||||
|
provider = _make_provider()
|
||||||
|
try:
|
||||||
|
payloads = {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "think", "think": "step 1"},
|
||||||
|
{"type": "text", "text": "final answer"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
provider._finally_convert_payload(payloads)
|
||||||
|
|
||||||
|
assistant_message = payloads["messages"][0]
|
||||||
|
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
|
||||||
|
assert assistant_message["reasoning_content"] == "step 1"
|
||||||
|
finally:
|
||||||
|
await provider.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_groq_payload_drops_reasoning_content_from_assistant_history():
|
||||||
|
provider = _make_groq_provider()
|
||||||
|
try:
|
||||||
|
payloads = {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "think", "think": "step 1"},
|
||||||
|
{"type": "text", "text": "final answer"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
provider._finally_convert_payload(payloads)
|
||||||
|
|
||||||
|
assistant_message = payloads["messages"][0]
|
||||||
|
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
|
||||||
|
assert "reasoning_content" not in assistant_message
|
||||||
|
assert "reasoning" not in assistant_message
|
||||||
|
finally:
|
||||||
|
await provider.terminate()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_api_error_content_moderated_without_images_raises():
|
async def test_handle_api_error_content_moderated_without_images_raises():
|
||||||
provider = _make_provider(
|
provider = _make_provider(
|
||||||
|
|||||||
@@ -804,6 +804,28 @@ class TestPluginToolFix:
|
|||||||
|
|
||||||
assert "mcp_tool" in req.func_tool.names()
|
assert "mcp_tool" in req.func_tool.names()
|
||||||
|
|
||||||
|
def test_plugin_tool_fix_preserves_tools_without_plugin_origin(self, mock_event):
|
||||||
|
"""Tools without handler_module_path should not be filtered out."""
|
||||||
|
module = ama
|
||||||
|
handoff_tool = FunctionTool(
|
||||||
|
name="transfer_to_demo_agent",
|
||||||
|
description="Delegate to demo agent",
|
||||||
|
parameters={"type": "object", "properties": {}},
|
||||||
|
handler_module_path=None,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_set = ToolSet()
|
||||||
|
tool_set.add_tool(handoff_tool)
|
||||||
|
|
||||||
|
req = ProviderRequest(func_tool=tool_set)
|
||||||
|
mock_event.plugins_name = ["other_plugin"]
|
||||||
|
|
||||||
|
with patch("astrbot.core.astr_main_agent.star_map"):
|
||||||
|
module._plugin_tool_fix(mock_event, req)
|
||||||
|
|
||||||
|
assert "transfer_to_demo_agent" in req.func_tool.names()
|
||||||
|
|
||||||
|
|
||||||
class TestBuildMainAgent:
|
class TestBuildMainAgent:
|
||||||
"""Tests for build_main_agent function."""
|
"""Tests for build_main_agent function."""
|
||||||
|
|||||||
Reference in New Issue
Block a user