Compare commits

...

14 Commits

Author SHA1 Message Date
LIghtJUNction
a743b49740 chore(deps): update sass to 1.98.0 2026-04-02 22:06:54 +08:00
LIghtJUNction
34bc57b9c6 chore: silence sass import deprecation warnings 2026-04-02 21:56:49 +08:00
LIghtJUNction
b63f06bffa feat: add api URL configurable support 2026-04-02 21:56:15 +08:00
氕氙
9d4472cb2d fix: 改进知识库的初始化错误处理 (#7243)
* fix: 改进 KnowledgeBaseManager 和 KBHelper 中的初始化错误处理

* fix: 改进知识库初始化和重排序错误处理,增强日志记录

* fix: 改进知识库模块初始化和检索错误处理

* fix(ui): handle kb init errors in list cards

display a dedicated error state for knowledge base cards that fail
initialization, including a visible badge and error details

prevent navigation and edit actions for failed cards while keeping
delete available, and hide normal stats/description for error items

add list.initError locale strings for en-US, ru-RU, and zh-CN

* fix(kb): avoid replacing helper on init failure

Initialize a new KB helper before swapping instances so a failed re-init
does not break the active knowledge base service.

If initialization fails, restore in-memory KB settings and keep the
existing helper and previous init error state.

Also clear stale init_error after successful vector DB initialization to
prevent outdated error reporting.

* test(kb): add kb manager resilience tests

cover initialization failure and recovery scenarios to guard
against regressions in kb error handling

include reference assets under refs for test validation

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-04-02 16:16:22 +08:00
M1LKT
e8d6938d31 Feat(webui): dashboard and console qol improvements (#7215)
* feat: support native fullscreen for log console

* feat: skip welcome page for configured users

* feat:  display plugin names under pinned icons

* fix: refine container styles
2026-04-02 13:40:21 +08:00
RichardLiu
206973e8ad Docs/update mimo provider readme (#7207)
* feat: add mimo tts provider support

* fix: handle empty mimo tts choices

* feat: add mimo stt provider support

* fix: align mimo tts style payload with official docs

* docs: add Xiaomi MiMo Omni and TTS services to multiple language READMEs
2026-04-02 10:25:19 +08:00
kaixinyujue
0ecddb4c06 修复:过滤空助手消息,以防止在严格API上出现400错误(fix: filter empty assistant messages to prevent 400 error on strict APIs) (#7202)
* fix: filter empty assistant messages to prevent 400 error on strict APIs

Some OpenAI-compatible APIs (e.g., Moonshot) reject requests with
empty content in assistant messages when no tool_calls are present.
This fix cleans up the messages payload before sending to avoid
'message at position X must not be empty' errors.

Closes related issue with fallback provider behavior.

* test(openai): add tests for empty assistant message filtering

* refactor(openai): simplify empty assistant message filtering logic

* style: format code

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-04-02 09:10:06 +08:00
Thelia
2de23184d0 Update connection success message for AstrBot (#7279) 2026-04-02 08:49:48 +08:00
Yufeng He
4d2791aa9a fix: support both old and new Bailian Rerank API response formats (#7217)
* fix: support both old and new Bailian Rerank API response formats

The new compatible API (compatible-api/v1/reranks) returns results at
the top level as data.results, while the old API returns them nested
under data.output.results. The parser only checked the old path,
causing qwen3-rerank to always report empty results.

Fixes #7161

* Update astrbot/core/provider/sources/bailian_rerank_source.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Ruochen Pan <badbatch0x01@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-01 11:55:54 +08:00
Chang Lee
3dd7799f27 fix(Docker): add amr encoder (#7247)
- Modified Dockerfile to add amr encoder (use libavcodec-extra)
2026-04-01 11:55:16 +08:00
NekoYukari
deedf85360 fix: add pysocks dependency to support SOCKS5 proxy for pip install (#7221)
* fix: add pysocks dependency to support SOCKS5 proxy for pip install

* docs: update proxy description to include https:// support

* fix: add python-socks and pysocks to requirements.txt for consistency

---------

Co-authored-by: root <root@localhost.localdomain>
2026-04-01 09:05:02 +08:00
Soulter
4d9dce184f feat: integrate Monaco Editor workers for enhanced code editing support (#7249)
fixes: #5587
2026-04-01 01:18:18 +08:00
Soulter
788d103a36 refactor: update provider panels for improved layout and styling (#7248) 2026-04-01 01:12:35 +08:00
LIghtJUNction
328748bd63 Fix cached_tokens handling in _extract_usage method (#6719)
* Fix cached_tokens handling in _extract_usage method

Ensure cached_tokens is an integer and handle None safely.

* ruuf format
2026-03-31 17:14:52 +08:00
42 changed files with 2027 additions and 377 deletions

2
.gitignore vendored
View File

@@ -64,3 +64,5 @@ GenieData/
.worktrees/
dashboard/bun.lock
.claude
.env

View File

@@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
bash \
ffmpeg \
libavcodec-extra \
curl \
gnupg \
git \

View File

@@ -184,6 +184,7 @@ Connect AstrBot to your favorite chat platform.
| Coze | LLMOps Platforms |
| OpenAI Whisper | Speech-to-Text Services |
| SenseVoice | Speech-to-Text Services |
| Xiaomi MiMo Omni | Speech-to-Text Services |
| OpenAI TTS | Text-to-Speech Services |
| Gemini TTS | Text-to-Speech Services |
| GPT-Sovits-Inference | Text-to-Speech Services |
@@ -193,6 +194,7 @@ Connect AstrBot to your favorite chat platform.
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
| Azure TTS | Text-to-Speech Services |
| Minimax TTS | Text-to-Speech Services |
| Xiaomi MiMo TTS | Text-to-Speech Services |
| Volcano Engine TTS | Text-to-Speech Services |
## ❤️ Sponsors

View File

@@ -184,6 +184,7 @@ Connectez AstrBot à vos plateformes de chat préférées.
| Coze | Plateformes LLMOps |
| OpenAI Whisper | Services de reconnaissance vocale |
| SenseVoice | Services de reconnaissance vocale |
| Xiaomi MiMo Omni | Services de reconnaissance vocale |
| OpenAI TTS | Services de synthèse vocale |
| Gemini TTS | Services de synthèse vocale |
| GPT-Sovits-Inference | Services de synthèse vocale |
@@ -193,6 +194,7 @@ Connectez AstrBot à vos plateformes de chat préférées.
| Alibaba Cloud Bailian TTS | Services de synthèse vocale |
| Azure TTS | Services de synthèse vocale |
| Minimax TTS | Services de synthèse vocale |
| Xiaomi MiMo TTS | Services de synthèse vocale |
| Volcano Engine TTS | Services de synthèse vocale |
## ❤️ Contribuer

View File

@@ -185,6 +185,7 @@ AstrBot をよく使うチャットプラットフォームに接続できます
| Coze | LLMOps プラットフォーム |
| OpenAI Whisper | 音声認識サービス |
| SenseVoice | 音声認識サービス |
| Xiaomi MiMo Omni | 音声認識サービス |
| OpenAI TTS | 音声合成サービス |
| Gemini TTS | 音声合成サービス |
| GPT-Sovits-Inference | 音声合成サービス |
@@ -194,6 +195,7 @@ AstrBot をよく使うチャットプラットフォームに接続できます
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
| Azure TTS | 音声合成サービス |
| Minimax TTS | 音声合成サービス |
| Xiaomi MiMo TTS | 音声合成サービス |
| Volcano Engine TTS | 音声合成サービス |
## ❤️ コントリビューション

View File

@@ -184,6 +184,7 @@ yay -S astrbot-git
| Coze | Платформы LLMOps |
| OpenAI Whisper | Сервисы распознавания речи |
| SenseVoice | Сервисы распознавания речи |
| Xiaomi MiMo Omni | Сервисы распознавания речи |
| OpenAI TTS | Сервисы синтеза речи |
| Gemini TTS | Сервисы синтеза речи |
| GPT-Sovits-Inference | Сервисы синтеза речи |
@@ -193,6 +194,7 @@ yay -S astrbot-git
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
| Azure TTS | Сервисы синтеза речи |
| Minimax TTS | Сервисы синтеза речи |
| Xiaomi MiMo TTS | Сервисы синтеза речи |
| Volcano Engine TTS | Сервисы синтеза речи |
## ❤️ Вклад в проект

View File

@@ -184,6 +184,7 @@ yay -S astrbot-git
| Coze | LLMOps 平台 |
| OpenAI Whisper | 語音轉文字服務 |
| SenseVoice | 語音轉文字服務 |
| Xiaomi MiMo Omni | 語音轉文字服務 |
| OpenAI TTS | 文字轉語音服務 |
| Gemini TTS | 文字轉語音服務 |
| GPT-Sovits-Inference | 文字轉語音服務 |
@@ -193,6 +194,7 @@ yay -S astrbot-git
| 阿里雲百煉 TTS | 文字轉語音服務 |
| Azure TTS | 文字轉語音服務 |
| Minimax TTS | 文字轉語音服務 |
| Xiaomi MiMo TTS | 文字轉語音服務 |
| 火山引擎 TTS | 文字轉語音服務 |
## ❤️ 貢獻

View File

@@ -185,6 +185,7 @@ yay -S astrbot-git
| Coze | LLMOps 平台 |
| OpenAI Whisper | 语音转文本 |
| SenseVoice | 语音转文本 |
| Xiaomi MiMo Omni | 语音转文本 |
| OpenAI TTS | 文本转语音 |
| Gemini TTS | 文本转语音 |
| GPT-Sovits-Inference | 文本转语音 |
@@ -194,6 +195,7 @@ yay -S astrbot-git
| 阿里云百炼 TTS | 文本转语音 |
| Azure TTS | 文本转语音 |
| Minimax TTS | 文本转语音 |
| Xiaomi MiMo TTS | 文本转语音 |
| 火山引擎 TTS | 文本转语音 |
## ❤️ 贡献

View File

@@ -15,7 +15,6 @@ class HandoffTool(FunctionTool, Generic[TContext]):
tool_description: str | None = None,
**kwargs,
) -> None:
# Avoid passing duplicate `description` to the FunctionTool dataclass.
# Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs
# to override what the main agent sees, while we also compute a default

View File

@@ -4040,9 +4040,9 @@ CONFIG_METADATA_3_SYSTEM = {
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab",
},
"http_proxy": {
"description": "HTTP 代理",
"description": "代理",
"type": "string",
"hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`",
"hint": "启用后,会以添加环境变量的方式设置代理。支持 http://、https://、socks5:// 格式例如http://127.0.0.1:7890 或 socks5://127.0.0.1:7891",
},
"no_proxy": {
"description": "直连地址列表",

View File

@@ -108,6 +108,7 @@ Text chunk to process:
class KBHelper:
vec_db: BaseVecDB
kb: KnowledgeBase
init_error: str | None
def __init__(
self,
@@ -122,6 +123,7 @@ class KBHelper:
self.prov_mgr = provider_manager
self.kb_root_dir = kb_root_dir
self.chunker = chunker
self.init_error = None
self.kb_dir = Path(self.kb_root_dir) / self.kb.kb_id
self.kb_medias_dir = Path(self.kb_dir) / "medias" / self.kb.kb_id
@@ -148,13 +150,14 @@ class KBHelper:
async def get_rp(self) -> RerankProvider | None:
if not self.kb.rerank_provider_id:
return None
rp: RerankProvider = await self.prov_mgr.get_provider_by_id(
rp: RerankProvider | None = await self.prov_mgr.get_provider_by_id(
self.kb.rerank_provider_id,
) # type: ignore
if not rp:
raise ValueError(
f"无法找到 ID 为 {self.kb.rerank_provider_id} 的 Rerank Provider",
logger.warning(
f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 的 Rerank Provider({self.kb.rerank_provider_id}) 不可用,将跳过重排序。",
)
return None
return rp
async def _ensure_vec_db(self) -> FaissVecDB:
@@ -162,7 +165,13 @@ class KBHelper:
raise ValueError(f"知识库 {self.kb.kb_name} 未配置 Embedding Provider")
ep = await self.get_ep()
rp = await self.get_rp()
rp: RerankProvider | None = None
try:
rp = await self.get_rp()
except Exception as e:
logger.warning(
f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 初始化重排序能力失败,将跳过重排序: {e}",
)
vec_db = FaissVecDB(
doc_store_path=str(self.kb_dir / "doc.db"),
@@ -172,6 +181,8 @@ class KBHelper:
)
await vec_db.initialize()
self.vec_db = vec_db
# Clear stale init_error once initialization succeeds.
self.init_error = None
return vec_db
async def delete_vec_db(self) -> None:
@@ -183,7 +194,7 @@ class KBHelper:
shutil.rmtree(self.kb_dir)
async def terminate(self) -> None:
if self.vec_db:
if hasattr(self, "vec_db") and self.vec_db:
await self.vec_db.close()
async def upload_document(

View File

@@ -1,4 +1,3 @@
import traceback
from pathlib import Path
from astrbot.core import logger
@@ -56,8 +55,7 @@ class KnowledgeBaseManager:
logger.error(f"知识库模块导入失败: {e}")
logger.warning("请确保已安装所需依赖: pypdf, aiofiles, Pillow, rank-bm25")
except Exception as e:
logger.error(f"知识库模块初始化失败: {e}")
logger.error(traceback.format_exc())
logger.error(f"知识库模块初始化失败: {e}", exc_info=True)
async def _init_kb_database(self) -> None:
self.kb_db = KBSQLiteDatabase(DB_PATH.as_posix())
@@ -76,7 +74,14 @@ class KnowledgeBaseManager:
kb_root_dir=FILES_PATH,
chunker=CHUNKER,
)
await kb_helper.initialize()
try:
await kb_helper.initialize()
except Exception as e:
kb_helper.init_error = str(e)
logger.error(
f"知识库 {record.kb_name}({record.kb_id}) 初始化失败: {e}",
exc_info=True,
)
self.kb_insts[record.kb_id] = kb_helper
async def create_kb(
@@ -179,6 +184,20 @@ class KnowledgeBaseManager:
return None
kb = kb_helper.kb
previous_state = {
"kb_name": kb.kb_name,
"description": kb.description,
"emoji": kb.emoji,
"embedding_provider_id": kb.embedding_provider_id,
"rerank_provider_id": kb.rerank_provider_id,
"chunk_size": kb.chunk_size,
"chunk_overlap": kb.chunk_overlap,
"top_k_dense": kb.top_k_dense,
"top_k_sparse": kb.top_k_sparse,
"top_m_final": kb.top_m_final,
}
previous_init_error = kb_helper.init_error
if kb_name is not None:
kb.kb_name = kb_name
if description is not None:
@@ -198,12 +217,47 @@ class KnowledgeBaseManager:
kb.top_k_sparse = top_k_sparse
if top_m_final is not None:
kb.top_m_final = top_m_final
# Build a new helper first. Keep current vec_db alive until new init succeeds.
new_helper = KBHelper(
kb_db=self.kb_db,
kb=kb,
provider_manager=self.provider_manager,
kb_root_dir=FILES_PATH,
chunker=CHUNKER,
)
try:
await new_helper.initialize()
except Exception as e:
# Roll back in-memory settings and keep current helper available.
kb.kb_name = previous_state["kb_name"]
kb.description = previous_state["description"]
kb.emoji = previous_state["emoji"]
kb.embedding_provider_id = previous_state["embedding_provider_id"]
kb.rerank_provider_id = previous_state["rerank_provider_id"]
kb.chunk_size = previous_state["chunk_size"]
kb.chunk_overlap = previous_state["chunk_overlap"]
kb.top_k_dense = previous_state["top_k_dense"]
kb.top_k_sparse = previous_state["top_k_sparse"]
kb.top_m_final = previous_state["top_m_final"]
kb_helper.init_error = previous_init_error
logger.error(
f"知识库 {kb.kb_name}({kb.kb_id}) 重新初始化失败,继续使用旧实例: {e}",
exc_info=True,
)
return kb_helper
async with self.kb_db.get_db() as session:
session.add(kb)
await session.commit()
await session.refresh(kb)
return kb_helper
old_helper = kb_helper
self.kb_insts[kb_id] = new_helper
await old_helper.terminate()
new_helper.init_error = None
return new_helper
async def retrieve(
self,
@@ -215,11 +269,21 @@ class KnowledgeBaseManager:
"""从指定知识库中检索相关内容"""
kb_ids = []
kb_id_helper_map = {}
unavailable_kbs = []
for kb_name in kb_names:
if kb_helper := await self.get_kb_by_name(kb_name):
if kb_helper.init_error:
unavailable_kbs.append((kb_name, kb_helper.init_error))
logger.warning(f"知识库 {kb_name} 不可用: {kb_helper.init_error}")
continue
kb_ids.append(kb_helper.kb.kb_id)
kb_id_helper_map[kb_helper.kb.kb_id] = kb_helper
# all requested KBs are unavailable
if not kb_ids and unavailable_kbs:
errors = "; ".join(f"{n}: {e}" for n, e in unavailable_kbs)
raise ValueError(f"所有请求的知识库均不可用: {errors}")
if not kb_ids:
return {}

View File

@@ -184,12 +184,15 @@ class RetrievalManager:
first_rerank = vec_db.rerank_provider
break
if first_rerank and retrieval_results:
retrieval_results = await self._rerank(
query=query,
results=retrieval_results,
top_k=top_m_final,
rerank_provider=first_rerank,
)
try:
retrieval_results = await self._rerank(
query=query,
results=retrieval_results,
top_k=top_m_final,
rerank_provider=first_rerank,
)
except Exception as e:
logger.warning(f"Rerank 执行失败,已跳过重排序并使用融合结果: {e}")
return retrieval_results[:top_m_final]
@@ -229,10 +232,10 @@ class RetrievalManager:
all_results.extend(vec_results)
except Exception as e:
from astrbot.core import logger
logger.warning(f"知识库 {kb_id} 稠密检索失败: {e}")
continue
logger.error(f"知识库 {kb_id} 稠密检索失败: {e}", exc_info=True)
if len(kb_ids) == 1:
raise RuntimeError(f"知识库 {kb_id} 稠密检索失败: {e}") from e
# multi-KB: skip the faulty KB and continue
# 按相似度排序并返回 top_k
all_results.sort(key=lambda x: x.similarity, reverse=True)

View File

@@ -142,7 +142,8 @@ class BailianRerankProvider(RerankProvider):
f"百炼 API 错误: {data.get('code')} {data.get('message', '')}"
)
results = data.get("output", {}).get("results", [])
# 兼容旧版 API (output.results) 和新版 compatible API (results)
results = (data.get("output") or {}).get("results") or data.get("results") or []
if not results:
logger.warning(f"百炼 Rerank 返回空结果: {data}")
return []

View File

@@ -457,6 +457,27 @@ class ProviderOpenAIOfficial(Provider):
model = payloads.get("model", "").lower()
if "messages" in payloads and isinstance(payloads["messages"], list):
cleaned_messages = []
for idx, msg in enumerate(payloads["messages"]):
# 过滤空的 assistant 消息,防止严格 API如 Moonshot返回 400 错误
if msg.get("role") == "assistant":
content = msg.get("content")
tool_calls = msg.get("tool_calls")
# 情况1: 空/null content 且无 tool_calls -> 过滤掉
if not tool_calls and (content == "" or content is None):
logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
continue
# 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范)
if content == "" and tool_calls:
msg["content"] = None
cleaned_messages.append(msg)
payloads["messages"] = cleaned_messages
completion = await self.client.chat.completions.create(
**payloads,
stream=False,
@@ -589,7 +610,10 @@ class ProviderOpenAIOfficial(Provider):
def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage:
ptd = getattr(usage, "prompt_tokens_details", None)
cached = getattr(ptd, "cached_tokens", 0) if ptd else 0
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
cached = (
cached if isinstance(cached, int) else 0
) # ptd.cached_tokens 可能为None
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0 # 安全
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
cached = cached or 0
prompt_tokens = prompt_tokens or 0

View File

@@ -394,7 +394,6 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
def find_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> set[str] | None:
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()

View File

@@ -314,7 +314,12 @@ class KnowledgeBaseRoute(Route):
# 转换为字典列表
kb_list = []
for kb in kbs:
kb_list.append(kb.model_dump())
kb_dict = kb.model_dump()
# include init_error from KBHelper if present
kb_helper = await kb_manager.get_kb(kb.kb_id)
if kb_helper and kb_helper.init_error:
kb_dict["init_error"] = kb_helper.init_error
kb_list.append(kb_dict)
return (
Response()

View File

@@ -14,7 +14,6 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"qrcode": "^1.5.4",
"@guolao/vue-monaco-editor": "^1.5.4",
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
@@ -35,6 +34,7 @@
"monaco-editor": "^0.52.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"vee-validate": "4.11.3",
@@ -61,7 +61,7 @@
"eslint": "8.48.0",
"eslint-plugin-vue": "9.17.0",
"prettier": "3.0.2",
"sass": "1.66.1",
"sass": "1.98.0",
"sass-loader": "13.3.2",
"subset-font": "^2.4.0",
"typescript": "5.1.6",

271
dashboard/pnpm-lock.yaml generated
View File

@@ -86,7 +86,7 @@ importers:
version: 4.11.3(vue@3.3.4)
vite-plugin-vuetify:
specifier: 2.1.3
version: 2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11)
version: 2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11)
vue:
specifier: 3.3.4
version: 3.3.4
@@ -129,7 +129,7 @@ importers:
version: 20.19.32
'@vitejs/plugin-vue':
specifier: 5.2.4
version: 5.2.4(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)
version: 5.2.4(vite@6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0))(vue@3.3.4)
'@vue/eslint-config-prettier':
specifier: 8.0.0
version: 8.0.0(@types/eslint@9.6.1)(eslint@8.48.0)(prettier@3.0.2)
@@ -149,11 +149,11 @@ importers:
specifier: 3.0.2
version: 3.0.2
sass:
specifier: 1.66.1
version: 1.66.1
specifier: 1.98.0
version: 1.98.0
sass-loader:
specifier: 13.3.2
version: 13.3.2(sass@1.66.1)(webpack@5.105.0)
version: 13.3.2(sass@1.98.0)(webpack@5.105.0)
subset-font:
specifier: ^2.4.0
version: 2.4.0
@@ -162,13 +162,13 @@ importers:
version: 5.1.6
vite:
specifier: 6.4.1
version: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
version: 6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0)
vite-plugin-webfont-dl:
specifier: ^3.12.0
version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))
version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0))
vue-cli-plugin-vuetify:
specifier: 2.5.8
version: 2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0)
version: 2.5.8(sass-loader@13.3.2(sass@1.98.0)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0)
vue-tsc:
specifier: 1.8.8
version: 1.8.8(typescript@5.1.6)
@@ -496,6 +496,94 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.6':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.6':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.6':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.6':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.6':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.6':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.6':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@pkgr/core@0.2.9':
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -1218,10 +1306,6 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
apexcharts@3.42.0:
resolution: {integrity: sha512-hYhzZqh2Efny9uiutkGU2M/EarJ4Nn8s6dxZ0C7E7N+SV4d1xjTioXi2NLn4UKVJabZkb3HnpXDoumXgtAymwg==}
@@ -1254,10 +1338,6 @@ packages:
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -1324,9 +1404,9 @@ packages:
chevrotain@11.0.3:
resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chrome-trace-event@1.0.4:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
@@ -1590,6 +1670,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -1966,10 +2050,6 @@ packages:
resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==}
engines: {node: '>= 0.10'}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-buffer@2.0.5:
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
engines: {node: '>=4'}
@@ -2235,13 +2315,12 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -2481,9 +2560,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
rechoir@0.6.2:
resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==}
@@ -2569,8 +2648,8 @@ packages:
sass-embedded:
optional: true
sass@1.66.1:
resolution: {integrity: sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==}
sass@1.98.0:
resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==}
engines: {node: '>=14.0.0'}
hasBin: true
@@ -3339,6 +3418,67 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@parcel/watcher-android-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-x64@2.5.6':
optional: true
'@parcel/watcher-freebsd-x64@2.5.6':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.6':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.6':
optional: true
'@parcel/watcher-win32-arm64@2.5.6':
optional: true
'@parcel/watcher-win32-ia32@2.5.6':
optional: true
'@parcel/watcher-win32-x64@2.5.6':
optional: true
'@parcel/watcher@2.5.6':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.3
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@pkgr/core@0.2.9': {}
'@popperjs/core@2.11.8': {}
@@ -3865,9 +4005,9 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)':
'@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0))(vue@3.3.4)':
dependencies:
vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
vite: 6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0)
vue: 3.3.4
'@volar/language-core@1.10.10':
@@ -4151,11 +4291,6 @@ snapshots:
dependencies:
color-convert: 2.0.1
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
apexcharts@3.42.0:
dependencies:
'@yr/monotone-cubic-spline': 1.0.3
@@ -4192,8 +4327,6 @@ snapshots:
big.js@5.2.2: {}
binary-extensions@2.3.0: {}
boolbase@1.0.0: {}
brace-expansion@1.1.12:
@@ -4267,17 +4400,9 @@ snapshots:
'@chevrotain/utils': 11.0.3
lodash-es: 4.17.23
chokidar@3.6.0:
chokidar@4.0.3:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
readdirp: 4.1.2
chrome-trace-event@1.0.4: {}
@@ -4547,6 +4672,9 @@ snapshots:
dequal@2.0.3: {}
detect-libc@2.1.2:
optional: true
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@@ -4967,10 +5095,6 @@ snapshots:
interpret@1.4.0: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-buffer@2.0.5: {}
is-core-module@2.16.1:
@@ -5226,9 +5350,10 @@ snapshots:
neo-async@2.6.2: {}
node-releases@2.0.36: {}
node-addon-api@7.1.1:
optional: true
normalize-path@3.0.0: {}
node-releases@2.0.36: {}
nth-check@2.1.1:
dependencies:
@@ -5486,9 +5611,7 @@ snapshots:
queue-microtask@1.2.3: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
readdirp@4.1.2: {}
rechoir@0.6.2:
dependencies:
@@ -5574,18 +5697,20 @@ snapshots:
safer-buffer@2.1.2: {}
sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0):
sass-loader@13.3.2(sass@1.98.0)(webpack@5.105.0):
dependencies:
neo-async: 2.6.2
webpack: 5.105.0
optionalDependencies:
sass: 1.66.1
sass: 1.98.0
sass@1.66.1:
sass@1.98.0:
dependencies:
chokidar: 3.6.0
chokidar: 4.0.3
immutable: 4.3.8
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.6
schema-utils@3.3.0:
dependencies:
@@ -5860,28 +5985,28 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-vuetify@2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11):
vite-plugin-vuetify@2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11):
dependencies:
'@vuetify/loader-shared': 2.1.2(vue@3.3.4)(vuetify@3.7.11)
debug: 4.4.3
upath: 2.0.1
vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
vite: 6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0)
vue: 3.3.4
vuetify: 3.7.11(typescript@5.1.6)(vite-plugin-vuetify@2.1.3)(vue@3.3.4)
transitivePeerDependencies:
- supports-color
vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)):
vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0)):
dependencies:
axios: 1.13.5
clean-css: 5.3.3
flat-cache: 6.1.20
picocolors: 1.1.1
vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
vite: 6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0)
transitivePeerDependencies:
- debug
vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0):
vite@6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -5892,7 +6017,7 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.32
fsevents: 2.3.3
sass: 1.66.1
sass: 1.98.0
terser: 5.46.0
vscode-jsonrpc@8.2.0: {}
@@ -5912,7 +6037,7 @@ snapshots:
vscode-uri@3.0.8: {}
vue-cli-plugin-vuetify@2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0):
vue-cli-plugin-vuetify@2.5.8(sass-loader@13.3.2(sass@1.98.0)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0):
dependencies:
null-loader: 4.0.1(webpack@5.105.0)
semver: 7.7.4
@@ -5920,7 +6045,7 @@ snapshots:
vue: 3.3.4
webpack: 5.105.0
optionalDependencies:
sass-loader: 13.3.2(sass@1.66.1)(webpack@5.105.0)
sass-loader: 13.3.2(sass@1.98.0)(webpack@5.105.0)
vuetify-loader: 2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0)
vue-demi@0.14.10(vue@3.3.4):
@@ -6002,7 +6127,7 @@ snapshots:
vue: 3.3.4
optionalDependencies:
typescript: 5.1.6
vite-plugin-vuetify: 2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11)
vite-plugin-vuetify: 2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.98.0)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11)
w3c-keyname@2.2.8: {}

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 256 icons */
/* Auto-generated MDI subset 255 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -132,10 +132,6 @@
content: "\F0B66";
}
.mdi-calendar-clock-outline::before {
content: "\F16E1";
}
.mdi-calendar-edit::before {
content: "\F08A7";
}
@@ -296,6 +292,10 @@
content: "\F0193";
}
.mdi-content-save-outline::before {
content: "\F0818";
}
.mdi-creation::before {
content: "\F0674";
}
@@ -844,10 +844,6 @@
content: "\F048A";
}
.mdi-send-clock-outline::before {
content: "\F1164";
}
.mdi-server::before {
content: "\F048B";
}

View File

@@ -65,18 +65,26 @@ const authorDisplay = computed(() => {
>
<v-menu offset-y>
<template #activator="{ props: menuProps }">
<v-avatar
v-bind="menuProps"
size="72"
class="pinned-avatar activator-avatar"
:title="plugin.display_name || plugin.name"
>
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
<div class="d-flex flex-column align-center" style="cursor: pointer; width: 80px;">
<v-avatar
v-bind="menuProps"
size="72"
class="pinned-avatar activator-avatar mb-1"
:title="plugin.display_name || plugin.name"
>
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
<span
class="text-caption text-center text-truncate"
style="width: 100%; font-size: 0.75rem; opacity: 0.9; line-height: 1.2;"
>
{{ plugin.display_name || plugin.name }}
</span>
</div>
</template>
<v-card>
@@ -182,8 +190,7 @@ const authorDisplay = computed(() => {
.pinned-card-wrapper {
position: relative;
display: inline-block;
width: 72px;
height: 72px;
width: 80px;
}
.pinned-item {

View File

@@ -1,8 +1,10 @@
<template>
<div class="mt-4">
<div class="d-flex align-center ga-2 mb-2">
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
<small style="color: grey;" v-if="availableCount">{{ tm('models.available') }} {{ availableCount }}</small>
<div class="provider-models-panel">
<div class="provider-models-head">
<div class="provider-models-title-wrap">
<h3 class="provider-models-title">{{ tm('models.configured') }}</h3>
<small v-if="availableCount" class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
</div>
<v-text-field
v-model="modelSearchProxy"
density="compact"
@@ -11,37 +13,35 @@
hide-details
variant="solo-filled"
flat
class="ml-1"
style="max-width: 240px;"
class="provider-models-search"
:placeholder="tm('models.searchPlaceholder')"
/>
<v-spacer></v-spacer>
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
@click="emit('fetch-models')"
variant="tonal"
size="small"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
size="small"
class="ml-1"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
</v-btn>
<div class="provider-models-actions">
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
@click="emit('fetch-models')"
variant="tonal"
size="small"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
size="small"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
</v-btn>
</div>
</div>
<v-list
density="compact"
class="rounded-lg border"
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;"
class="provider-models-list"
>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
@@ -55,7 +55,7 @@
<v-list-item-title class="font-weight-medium text-truncate">
{{ entry.provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
<span>{{ entry.provider.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
@@ -124,7 +124,7 @@
<template #activator="{ props }">
<v-list-item v-bind="props" class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
<v-list-item-title>{{ entry.model }}</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
<span>{{ entry.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
@@ -231,11 +231,87 @@ const isProviderTesting = (providerId) => props.testingProviders.includes(provid
</script>
<style scoped>
.border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
.provider-models-panel {
display: grid;
gap: 14px;
}
.provider-models-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.provider-models-title-wrap {
min-width: 0;
}
.provider-models-title {
margin: 0;
font-size: 18px;
line-height: 1.3;
font-weight: 650;
}
.provider-models-subtitle {
display: block;
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 12px;
}
.provider-models-search {
max-width: 240px;
}
.provider-models-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.provider-models-list {
max-height: 520px;
overflow-y: auto;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 14px;
background: rgb(var(--v-theme-surface));
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.provider-compact-item {
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.provider-models-list :deep(.v-list-item:last-child) {
border-bottom: 0;
}
.provider-model-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.cursor-pointer {
cursor: pointer;
}
@media (max-width: 900px) {
.provider-models-head {
align-items: stretch;
}
.provider-models-search {
max-width: none;
width: 100%;
}
.provider-models-actions {
margin-left: 0;
width: 100%;
}
}
</style>

View File

@@ -1,8 +1,13 @@
<template>
<v-card class="provider-sources-panel h-100" elevation="0">
<div class="d-flex align-center justify-space-between px-4 pt-4 pb-2">
<div class="d-flex align-center ga-2">
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
<div class="provider-sources-head">
<div class="provider-sources-title-wrap">
<div class="provider-sources-title-row">
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
<v-chip size="x-small" variant="tonal" label>
{{ displayedProviderSources.length }}
</v-chip>
</div>
</div>
<StyledMenu>
<template #activator="{ props }">
@@ -11,7 +16,6 @@
prepend-icon="mdi-plus"
color="primary"
variant="tonal"
rounded="xl"
size="small"
>
{{ tm('providerSources.add') }}
@@ -34,7 +38,7 @@
</StyledMenu>
</div>
<div v-if="isMobile && displayedProviderSources.length > 0" class="px-4 pb-3">
<div v-if="isMobile && displayedProviderSources.length > 0" class="provider-sources-mobile">
<div class="d-flex align-center ga-2">
<v-select
:model-value="selectedId"
@@ -71,7 +75,7 @@
</div>
</div>
<div v-else-if="displayedProviderSources.length > 0">
<div v-else-if="displayedProviderSources.length > 0" class="provider-sources-list-wrap">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
@@ -83,13 +87,13 @@
@click="emitSelectSource(source)"
>
<template #prepend>
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
<v-avatar size="28" class="provider-source-avatar" rounded="0">
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
<v-icon v-else size="32">mdi-creation</v-icon>
<v-icon v-else size="20">mdi-creation</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold mb-1" style="font-family: Arial, Helvetica, sans-serif; font-size: 16px;">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<v-list-item-title class="provider-source-title">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="provider-source-subtitle text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1">
<v-btn
@@ -98,6 +102,7 @@
variant="text"
size="x-small"
color="error"
:ripple="false"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
@@ -181,22 +186,98 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
<style scoped>
.provider-sources-panel {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 16px;
background: rgb(var(--v-theme-surface));
min-height: 320px;
overflow: hidden;
}
.provider-sources-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 18px 18px 12px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
}
.provider-sources-title-row {
display: flex;
align-items: center;
gap: 6px;
}
.provider-sources-title {
margin: 0;
font-size: 17px;
line-height: 1.2;
font-weight: 650;
}
.provider-sources-mobile {
padding: 14px 18px 0;
}
.provider-sources-list-wrap {
padding: 8px 8px 10px;
}
.provider-source-list {
max-height: calc(100vh - 335px);
overflow-y: auto;
padding: 6px 8px;
padding: 0;
background: transparent;
}
.provider-source-list-item {
margin-bottom: 2px;
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
background: transparent;
}
.provider-source-list-item--active {
background-color: #E8F0FE;
border: 1px solid rgba(var(--v-theme-primary), 0.25);
background-color: rgba(var(--v-theme-primary), 0.06);
border: 1px solid transparent;
}
.provider-source-avatar {
background: transparent !important;
}
.provider-source-title {
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.provider-source-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 12px;
line-height: 1.5;
}
.provider-source-list :deep(.v-list-item__prepend) {
margin-inline-end: 10px;
}
.provider-source-list :deep(.v-list-item__content) {
min-width: 0;
}
.provider-source-list :deep(.v-list-item__append) {
opacity: 0;
transition: opacity 0.15s ease;
}
.provider-source-list-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.025);
}
.provider-source-list-item:hover :deep(.v-list-item__append),
.provider-source-list-item--active :deep(.v-list-item__append) {
opacity: 1;
}
@media (max-width: 960px) {
@@ -212,7 +293,7 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
<style>
.v-theme--PurpleThemeDark .provider-source-list-item--active {
background-color: #2d2d2d;
border: none;
background-color: rgba(var(--v-theme-primary), 0.1);
border: 1px solid transparent;
}
</style>

View File

@@ -5,7 +5,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
</script>
<template>
<div>
<div class="console-displayer-wrapper" id="console-wrapper">
<div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter variant="flat" size="small"
@@ -13,6 +13,14 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
{{ level }}
</v-chip>
</v-chip-group>
<v-spacer></v-spacer>
<v-btn
:icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
variant="text"
density="compact"
class="me-4 fullscreen-btn"
@click="toggleFullscreen"
></v-btn>
</div>
<div id="term" style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto; height: 100%">
@@ -26,6 +34,7 @@ export default {
data() {
return {
autoScroll: true,
isFullscreen: false,
logColorAnsiMap: {
'\u001b[1;34m': 'color: #39C5BB; font-weight: bold;',
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;',
@@ -80,8 +89,10 @@ export default {
async mounted() {
await this.fetchLogHistory();
this.connectSSE();
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
},
beforeUnmount() {
document.removeEventListener('fullscreenchange', this.handleFullscreenChange);
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
@@ -253,6 +264,21 @@ export default {
this.autoScroll = !this.autoScroll;
},
toggleFullscreen() {
const container = document.getElementById('console-wrapper');
if (!document.fullscreenElement) {
container.requestFullscreen().catch(err => {
console.error(`Error attempting to enable full-screen mode: ${err.message}`);
});
} else {
document.exitFullscreen();
}
},
handleFullscreenChange() {
this.isFullscreen = !!document.fullscreenElement;
},
printLog(log) {
let ele = document.getElementById('term')
if (!ele) {
@@ -282,14 +308,30 @@ export default {
</script>
<style scoped>
.console-displayer-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
#console-wrapper:fullscreen {
background-color: #1e1e1e;
padding: 20px;
}
.filter-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
margin-left: 20px;
}
.fullscreen-btn {
color: rgba(255, 255, 255, 0.7) !important; /* 提高在深色背景下的对比度 */
}
:deep(.console-log-line) {
display: block;
margin-bottom: 2px;

View File

@@ -10,7 +10,8 @@
"loading": "Loading...",
"documents": "Documents",
"chunks": "Chunks",
"sessionConfig": "Session Config"
"sessionConfig": "Session Config",
"initError": "Initialization Failed"
},
"card": {
"edit": "Edit",

View File

@@ -10,7 +10,8 @@
"loading": "Загрузка...",
"documents": "док.",
"chunks": "фрагм.",
"sessionConfig": "Профиль"
"sessionConfig": "Профиль",
"initError": "Ошибка инициализации"
},
"card": {
"edit": "Изменить",

View File

@@ -10,7 +10,8 @@
"loading": "正在加载...",
"documents": "文档",
"chunks": "分块",
"sessionConfig": "会话配置"
"sessionConfig": "会话配置",
"initError": "初始化失败"
},
"card": {
"edit": "编辑",

View File

@@ -1,119 +1,193 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import vuetify from './plugins/vuetify';
import confirmPlugin from './plugins/confirmPlugin';
import { setupI18n } from './i18n/composables';
import '@/scss/style.scss';
import VueApexCharts from 'vue3-apexcharts';
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";
import vuetify from "./plugins/vuetify";
import confirmPlugin from "./plugins/confirmPlugin";
import { setupI18n } from "./i18n/composables";
import "@/scss/style.scss";
import VueApexCharts from "vue3-apexcharts";
import print from 'vue3-print-nb';
import { loader } from '@guolao/vue-monaco-editor'
import axios from 'axios';
import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs';
import print from "vue3-print-nb";
import { loader } from "@guolao/vue-monaco-editor";
import * as monaco from "monaco-editor";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
import axios from "axios";
import { waitForRouterReadyInBackground } from "./utils/routerReadiness.mjs";
import {
getApiBaseUrl,
resolveApiUrl,
resolvePublicUrl,
setApiBaseUrl,
} from "@/utils/request";
(self as any).MonacoEnvironment = {
getWorker(_: string, label: string) {
if (label === "json") {
return new jsonWorker();
}
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker();
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
};
// 初始化新的i18n系统等待完成后再挂载应用
setupI18n().then(async () => {
console.log('🌍 新i18n系统初始化完成');
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
await router.isReady();
app.mount('#app');
// 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
const storedPrimary = localStorage.getItem('themePrimary');
const storedSecondary = localStorage.getItem('themeSecondary');
if (storedPrimary || storedSecondary) {
const themes = vuetify.theme.themes.value;
['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
const theme = themes[name];
if (!theme?.colors) return;
if (storedPrimary) theme.colors.primary = storedPrimary;
if (storedSecondary) theme.colors.secondary = storedSecondary;
if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary;
if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary;
});
}
});
}).catch(error => {
console.error('❌ 新i18n系统初始化失败:', error);
// 即使i18n初始化失败也要挂载应用使用回退机制
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
waitForRouterReadyInBackground(router);
// 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
const storedPrimary = localStorage.getItem('themePrimary');
const storedSecondary = localStorage.getItem('themeSecondary');
if (storedPrimary || storedSecondary) {
const themes = vuetify.theme.themes.value;
['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
const theme = themes[name];
if (!theme?.colors) return;
if (storedPrimary) theme.colors.primary = storedPrimary;
if (storedSecondary) theme.colors.secondary = storedSecondary;
if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary;
if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary;
});
}
});
});
setupI18n()
.then(async () => {
console.log("🌍 新i18n系统初始化完成");
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
await router.isReady();
app.mount("#app");
// 挂载后同步 Vuetify 主题
import("./stores/customizer").then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
const storedPrimary = localStorage.getItem("themePrimary");
const storedSecondary = localStorage.getItem("themeSecondary");
if (storedPrimary || storedSecondary) {
const themes = vuetify.theme.themes.value;
["PurpleTheme", "PurpleThemeDark"].forEach((name) => {
const theme = themes[name];
if (!theme?.colors) return;
if (storedPrimary) theme.colors.primary = storedPrimary;
if (storedSecondary) theme.colors.secondary = storedSecondary;
if (storedPrimary && theme.colors.darkprimary)
theme.colors.darkprimary = storedPrimary;
if (storedSecondary && theme.colors.darksecondary)
theme.colors.darksecondary = storedSecondary;
});
}
});
})
.catch((error) => {
console.error("❌ 新i18n系统初始化失败:", error);
// 即使i18n初始化失败也要挂载应用使用回退机制
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
app.mount("#app");
waitForRouterReadyInBackground(router);
// 挂载后同步 Vuetify 主题
import("./stores/customizer").then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
const storedPrimary = localStorage.getItem("themePrimary");
const storedSecondary = localStorage.getItem("themeSecondary");
if (storedPrimary || storedSecondary) {
const themes = vuetify.theme.themes.value;
["PurpleTheme", "PurpleThemeDark"].forEach((name) => {
const theme = themes[name];
if (!theme?.colors) return;
if (storedPrimary) theme.colors.primary = storedPrimary;
if (storedSecondary) theme.colors.secondary = storedSecondary;
if (storedPrimary && theme.colors.darkprimary)
theme.colors.darkprimary = storedPrimary;
if (storedSecondary && theme.colors.darksecondary)
theme.colors.darksecondary = storedSecondary;
});
}
});
});
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
const token = localStorage.getItem("token");
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
config.headers["Authorization"] = `Bearer ${token}`;
}
const locale = localStorage.getItem('astrbot-locale');
const locale = localStorage.getItem("astrbot-locale");
if (locale) {
config.headers['Accept-Language'] = locale;
config.headers["Accept-Language"] = locale;
}
return config;
});
// Keep fetch() calls consistent with axios by automatically attaching the JWT.
// Some parts of the UI use fetch directly; without this, those requests will 401.
const _origFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const token = localStorage.getItem('token');
if (!token) return _origFetch(input, init);
const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined));
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
// 1. 定义加载配置的函数
async function loadAppConfig() {
try {
const configUrl = new URL(resolvePublicUrl("config.json"));
configUrl.searchParams.set("t", `${Date.now()}`);
const response = await fetch(configUrl.toString());
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn("Failed to load config.json, falling back to default.", error);
return {};
}
const locale = localStorage.getItem('astrbot-locale');
if (locale && !headers.has('Accept-Language')) {
headers.set('Accept-Language', locale);
}
return _origFetch(input, { ...init, headers });
};
}
loader.config({
paths: {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs',
},
})
async function initApp() {
const config = await loadAppConfig();
const configApiUrl = config.apiBaseUrl || "";
const envApiUrl = import.meta.env.VITE_API_BASE || "";
const localApiUrl = localStorage.getItem("apiBaseUrl");
const apiBaseUrl =
localApiUrl !== null ? localApiUrl : configApiUrl || envApiUrl;
if (apiBaseUrl) {
console.log(`API Base URL set to: ${apiBaseUrl}`);
}
setApiBaseUrl(apiBaseUrl);
// Keep fetch() calls consistent with axios by automatically attaching the JWT.
// Some parts of the UI use fetch directly; without this, those requests will 401.
const _origFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
let url = input;
if (typeof input === "string" && input.startsWith("/api")) {
url = resolveApiUrl(input, getApiBaseUrl());
}
const token = localStorage.getItem("token");
const headers = new Headers(
init?.headers ||
(typeof input !== "string" && "headers" in input
? (input as Request).headers
: undefined),
);
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
const locale = localStorage.getItem("astrbot-locale");
if (locale && !headers.has("Accept-Language")) {
headers.set("Accept-Language", locale);
}
return _origFetch(url, { ...init, headers });
};
}
initApp();
loader.config({ monaco });

View File

@@ -25,12 +25,47 @@ export const useAuthStore = defineStore({
localStorage.setItem('user', this.username);
localStorage.setItem('token', res.data.data.token);
localStorage.setItem('change_pwd_hint', res.data.data?.change_pwd_hint);
const onboardingCompleted = await this.checkOnboardingCompleted();
this.returnUrl = null;
router.push('/welcome');
if (onboardingCompleted) {
router.push('/dashboard/default');
} else {
router.push('/welcome');
}
} catch (error) {
return Promise.reject(error);
}
},
async checkOnboardingCompleted(): Promise<boolean> {
try {
// 1. 检查平台配置
const platformRes = await axios.get('/api/config/get');
const hasPlatform = (platformRes.data.data.config.platform || []).length > 0;
if (!hasPlatform) return false;
// 2. 检查提供者配置
const providerRes = await axios.get('/api/config/provider/template');
const providers = providerRes.data.data?.providers || [];
const sources = providerRes.data.data?.provider_sources || [];
const sourceMap = new Map();
sources.forEach((s: any) => sourceMap.set(s.id, s.provider_type));
const hasProvider = providers.some((provider: any) => {
if (provider.provider_type) return provider.provider_type === 'chat_completion';
if (provider.provider_source_id) {
const type = sourceMap.get(provider.provider_source_id);
if (type === 'chat_completion') return true;
}
return String(provider.type || '').includes('chat_completion');
});
return hasProvider;
} catch (e) {
console.error('Failed to check onboarding status:', e);
return false;
}
},
logout() {
this.username = '';
localStorage.removeItem('user');

View File

@@ -0,0 +1,182 @@
import axios, { type InternalAxiosRequestConfig } from "axios";
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;
function isAbsoluteUrl(value: string): boolean {
return ABSOLUTE_URL_PATTERN.test(value);
}
function stripTrailingSlashes(value: string): string {
return value.replace(/\/+$/, "");
}
function ensureLeadingSlash(value: string): string {
if (!value) {
return "/";
}
return value.startsWith("/") ? value : `/${value}`;
}
function stripLeadingApiPrefix(path: string): string {
const normalizedPath = ensureLeadingSlash(path);
const strippedPath = normalizedPath.replace(/^\/api(?=\/|$)/, "");
return strippedPath || "/";
}
function baseEndsWithApi(baseUrl: string): boolean {
if (!baseUrl) {
return false;
}
if (isAbsoluteUrl(baseUrl)) {
try {
return new URL(baseUrl).pathname.replace(/\/+$/, "").endsWith("/api");
} catch {
return baseUrl.replace(/\/+$/, "").endsWith("/api");
}
}
return stripTrailingSlashes(baseUrl).endsWith("/api");
}
function normalizePathForBase(path: string, baseUrl = ""): string {
if (!path) {
return "/";
}
if (isAbsoluteUrl(path)) {
return path;
}
const normalizedPath = ensureLeadingSlash(path);
if (baseEndsWithApi(baseUrl)) {
return stripLeadingApiPrefix(normalizedPath);
}
return normalizedPath;
}
function joinBaseAndPath(baseUrl: string, path: string): string {
const cleanBase = stripTrailingSlashes(baseUrl);
const cleanPath = path.replace(/^\/+/, "");
return `${cleanBase}/${cleanPath}`;
}
function normalizeBaseUrl(baseUrl: string | null | undefined): string {
return stripTrailingSlashes(baseUrl?.trim() || "");
}
export function normalizeConfiguredApiBaseUrl(
baseUrl: string | null | undefined,
): string {
return normalizeBaseUrl(baseUrl);
}
export function getApiBaseUrlValidationError(
baseUrl: string | null | undefined,
): string {
const normalizedBaseUrl = normalizeConfiguredApiBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return "";
}
try {
const parsedUrl = new URL(normalizedBaseUrl);
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
return "API Base URL must use http:// or https://";
}
} catch {
return "API Base URL must be a valid absolute URL";
}
return "";
}
export function getApiBaseUrl(): string {
return normalizeBaseUrl(service.defaults.baseURL);
}
export function setApiBaseUrl(baseUrl: string | null | undefined): string {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
service.defaults.baseURL = normalizedBaseUrl;
return normalizedBaseUrl;
}
export function resolveApiUrl(
path: string,
baseUrl: string | null | undefined = getApiBaseUrl(),
): string {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const normalizedPath = normalizePathForBase(path, normalizedBaseUrl);
if (isAbsoluteUrl(normalizedPath)) {
return normalizedPath;
}
if (!normalizedBaseUrl) {
return normalizedPath;
}
return joinBaseAndPath(normalizedBaseUrl, normalizedPath);
}
export function resolvePublicUrl(path: string): string {
const base = import.meta.env.BASE_URL || "/";
const cleanBase = base.endsWith("/") ? base : `${base}/`;
return new URL(
path.replace(/^\/+/, ""),
window.location.origin + cleanBase,
).toString();
}
export function resolveWebSocketUrl(
path: string,
queryParams?: Record<string, string>,
): string {
const resolvedApiUrl = resolveApiUrl(path);
const url = new URL(resolvedApiUrl, window.location.href);
if (url.protocol === "https:") {
url.protocol = "wss:";
} else if (url.protocol === "http:") {
url.protocol = "ws:";
}
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
return url.toString();
}
const service = axios.create({
baseURL: normalizeBaseUrl(import.meta.env.VITE_API_BASE),
timeout: 10000,
});
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const normalizedBaseUrl = normalizeBaseUrl(
config.baseURL ?? service.defaults.baseURL,
);
if (typeof config.url === "string") {
config.url = normalizePathForBase(config.url, normalizedBaseUrl);
}
const token = localStorage.getItem("token");
if (token) {
config.headers.set("Authorization", `Bearer ${token}`);
}
const locale = localStorage.getItem("astrbot-locale");
if (locale) {
config.headers.set("Accept-Language", locale);
}
return config;
});
export default service;
export * from "axios";

View File

@@ -94,7 +94,21 @@
<template v-slot:item.title="{ item }">
<div class="conversation-title-cell">
<span class="conversation-title-text">{{ item.title || tm('status.noTitle') }}</span>
<div class="conversation-title-row">
<span class="conversation-title-text">{{ item.title || tm('status.noTitle') }}</span>
<v-btn
icon
variant="plain"
size="x-small"
density="compact"
:ripple="false"
class="conversation-inline-edit"
@click.stop="editConversation(item)"
:disabled="loading"
>
<v-icon size="14">mdi-pencil</v-icon>
</v-btn>
</div>
<span class="conversation-title-meta">{{ item.cid || tm('status.unknown') }}</span>
</div>
</template>
@@ -141,10 +155,6 @@
@click="viewConversation(item)" :disabled="loading">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon variant="plain" size="x-small" class="action-button"
@click="editConversation(item)" :disabled="loading">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="error" variant="plain" size="x-small" class="action-button"
@click="confirmDeleteConversation(item)" :disabled="loading">
<v-icon>mdi-delete</v-icon>
@@ -1188,14 +1198,29 @@ export default {
max-width: 145px;
}
.conversation-title-row {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.conversation-title-text {
display: inline-block;
width: 100%;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-inline-edit {
width: 18px;
height: 18px;
min-width: 18px;
flex-shrink: 0;
}
.conversation-title-meta {
display: block;
color: rgba(var(--v-theme-on-surface), 0.58);

View File

@@ -29,9 +29,9 @@
</v-tabs>
<!-- Chat Completion: 左侧列表 + 右侧上下卡片布局 -->
<div v-if="selectedProviderType === 'chat_completion'" class="d-flex align-center justify-center">
<v-row style="max-width: 1500px; ">
<v-col cols="12" md="4" lg="3" class="pr-md-4">
<div v-if="selectedProviderType === 'chat_completion'" class="provider-workbench">
<v-row class="provider-workbench__shell">
<v-col cols="12" md="4" lg="3" class="provider-workbench__sources">
<ProviderSourcesPanel
:displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource"
@@ -45,66 +45,77 @@
/>
</v-col>
<v-col cols="12" md="8" lg="9">
<v-card class="provider-config-card h-100" elevation="0" style="overflow-y: auto;">
<v-card-title class="d-flex align-center justify-space-between flex-wrap ga-3 pt-4 pl-5">
<div class="d-flex align-center ga-3" v-if="selectedProviderSource">
<div>
<div class="text-h4 font-weight-bold">{{ selectedProviderSource.id }}</div>
<div class="text-caption text-medium-emphasis">{{ selectedProviderSource.api_base || 'N/A' }}
</div>
<v-col cols="12" md="8" lg="9" class="provider-workbench__settings">
<v-card class="provider-config-card provider-settings-panel h-100" elevation="0">
<div v-if="selectedProviderSource" class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-kicker">{{ tm('providers.settings') }}</div>
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<div class="d-flex align-center ga-2" v-if="selectedProviderSource">
<v-btn color="success" prepend-icon="mdi-check" :loading="savingSource"
:disabled="!isSourceModified" @click="saveProviderSource" variant="flat">
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
@click="saveProviderSource"
variant="tonal"
>
{{ tm('providerSources.save') }}
</v-btn>
</div>
</v-card-title>
</div>
<v-card-text>
<v-card-text class="provider-config-body">
<template v-if="selectedProviderSource">
<div>
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="providerSourceSchema"
metadataKey="provider" :is-editing="true" />
</div>
</section>
<v-expansion-panels variant="accordion" class="mb-2">
<v-expansion-panel elevation="0" class="border rounded-lg">
<v-expansion-panel-title>
<span class="font-weight-medium">{{ tm('providerSources.advancedConfig') }}</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<AstrBotConfig v-if="advancedSourceConfig" :iterable="advancedSourceConfig"
:metadata="providerSourceSchema" metadataKey="provider" :is-editing="true" />
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</section>
</template>
<div v-else class="text-center py-8 text-medium-emphasis">
<div v-else class="provider-empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
</div>
@@ -665,18 +676,141 @@ function goToConfigPage() {
<style scoped>
.provider-page {
--provider-surface: rgb(var(--v-theme-surface));
--provider-text: rgb(var(--v-theme-on-surface));
--provider-muted: rgba(var(--v-theme-on-surface), 0.68);
--provider-subtle: rgba(var(--v-theme-on-surface), 0.56);
--provider-border: rgba(var(--v-theme-on-surface), 0.1);
--provider-border-strong: rgba(var(--v-theme-on-surface), 0.14);
--provider-soft: rgba(var(--v-theme-primary), 0.08);
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
.provider-workbench {
display: flex;
justify-content: center;
}
.provider-workbench__shell {
width: 100%;
max-width: 1500px;
}
.provider-workbench__sources,
.provider-workbench__settings {
min-width: 0;
}
.provider-config-card {
min-height: 280px;
border: 1px solid var(--provider-border);
border-radius: 16px;
background: var(--provider-surface);
overflow: hidden;
}
.provider-settings-panel {
display: flex;
flex-direction: column;
}
.provider-config-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 20px 20px 16px;
border-bottom: 1px solid var(--provider-border);
}
.provider-config-headline {
min-width: 0;
}
.provider-config-kicker {
color: var(--provider-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.provider-config-title {
margin-top: 8px;
font-size: 22px;
line-height: 1.1;
font-weight: 650;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
color: var(--provider-text);
}
.provider-config-subtitle {
margin-top: 8px;
color: var(--provider-muted);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.provider-config-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.provider-config-body {
display: grid;
gap: 14px;
padding: 18px 20px 20px;
}
.provider-section {
border: 1px solid var(--provider-border);
border-radius: 14px;
background: rgba(var(--v-theme-primary), 0.02);
padding: 16px;
}
.provider-section--models {
padding: 18px;
}
.provider-section-head {
margin-bottom: 10px;
}
.provider-section-title {
font-size: 16px;
font-weight: 650;
line-height: 1.4;
color: var(--provider-text);
}
.provider-empty-state {
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--provider-muted);
}
@media (max-width: 960px) {
.provider-config-card {
min-height: auto;
}
.provider-config-header {
flex-direction: column;
align-items: flex-start;
}
.provider-config-body {
padding: 18px;
}
}
</style>

View File

@@ -22,10 +22,15 @@ function toggleTheme() {
theme.global.name.value = newTheme;
}
onMounted(() => {
onMounted(async () => {
// 检查用户是否已登录,如果已登录则重定向
if (authStore.has_token()) {
router.push('/welcome');
const onboardingCompleted = await authStore.checkOnboardingCompleted();
if (onboardingCompleted) {
router.push('/dashboard/default');
} else {
router.push('/welcome');
}
return;
}

View File

@@ -27,14 +27,27 @@
</div>
<div v-else-if="kbList.length > 0" class="kb-grid">
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" hover
@click="navigateToDetail(kb.kb_id)">
<div class="kb-card-content">
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" :hover="!kb.init_error"
:class="{ 'kb-card-error': kb.init_error }"
@click="!kb.init_error && navigateToDetail(kb.kb_id)">
<!-- Error badge -->
<v-badge v-if="kb.init_error" color="error" icon="mdi-alert-circle"
class="kb-error-badge position-absolute" style="top: 0; right: 0; transform: translate(34%, -34%);" />
<div class="kb-card-content" :class="{ 'kb-card-content-error': kb.init_error }">
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
<h3 class="kb-name">{{ kb.kb_name }}</h3>
<p class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
<p v-if="!kb.init_error" class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
<div class="kb-stats mt-4">
<!-- Error message display -->
<div v-if="kb.init_error" class="kb-error-panel mt-3 mb-2">
<div class="kb-error-title">
<v-icon size="16" color="error">mdi-close-circle</v-icon>
<span>{{ t('list.initError') }}</span>
</div>
<div class="kb-error-detail" :title="kb.init_error">{{ kb.init_error }}</div>
</div>
<div class="kb-stats mt-4" v-if="!kb.init_error">
<div class="stat-item">
<v-icon size="small" color="primary">mdi-file-document</v-icon>
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
@@ -45,8 +58,8 @@
</div>
</div>
<div class="kb-actions">
<v-btn icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
<div class="kb-actions" :class="{ 'error-actions': kb.init_error }">
<v-btn v-if="!kb.init_error" icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="confirmDelete(kb)" />
</div>
</div>
@@ -478,6 +491,34 @@ onMounted(() => {
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
}
/* Error state card styles */
.kb-card-error {
cursor: not-allowed;
border: 1px solid rgba(var(--v-theme-error), 0.3);
background-color: rgba(var(--v-theme-error), 0.02) !important;
overflow: visible; /* Allow badge to overflow */
}
.kb-card-error:hover {
transform: none;
box-shadow: 0 4px 12px rgba(var(--v-theme-error), 0.1) !important;
border-color: rgba(var(--v-theme-error), 0.5);
}
.kb-card-error .kb-emoji {
opacity: 0.7;
filter: grayscale(0.5);
}
.kb-card-error .kb-name {
color: rgba(var(--v-theme-on-surface), 0.7);
}
.kb-error-badge {
z-index: 10;
opacity: 0.9;
}
.kb-card-content {
padding: 24px;
display: flex;
@@ -488,6 +529,11 @@ onMounted(() => {
position: relative;
}
.kb-card-content-error {
justify-content: center;
gap: 8px;
}
.kb-emoji {
font-size: 56px;
margin-bottom: 8px;
@@ -518,6 +564,36 @@ onMounted(() => {
justify-content: center;
}
.kb-error-panel {
width: 100%;
text-align: left;
background: rgba(var(--v-theme-error), 0.08);
border: 1px solid rgba(var(--v-theme-error), 0.18);
border-radius: 10px;
padding: 10px 12px;
}
.kb-error-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
font-weight: 600;
color: rgb(var(--v-theme-error));
margin-bottom: 4px;
}
.kb-error-detail {
font-size: 0.78rem;
line-height: 1.35;
color: rgba(var(--v-theme-on-surface), 0.82);
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.stat-item {
display: flex;
align-items: center;

View File

@@ -1,17 +1,17 @@
import { fileURLToPath, URL } from 'url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vuetify from 'vite-plugin-vuetify';
import webfontDl from 'vite-plugin-webfont-dl';
import { fileURLToPath, URL } from "url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vuetify from "vite-plugin-vuetify";
import webfontDl from "vite-plugin-webfont-dl";
// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime
import { runMdiSubset } from './scripts/subset-mdi-font.mjs';
import { runMdiSubset } from "./scripts/subset-mdi-font.mjs";
// Vite plugin: run MDI icon font subsetting (build only)
function mdiSubset() {
return {
name: 'vite-plugin-mdi-subset',
name: "vite-plugin-mdi-subset",
async buildStart() {
console.log('\n🔧 Running MDI icon font subsetting...');
console.log("\n🔧 Running MDI icon font subsetting...");
await runMdiSubset();
},
};
@@ -21,47 +21,50 @@ function mdiSubset() {
export default defineConfig(({ command }) => ({
plugins: [
// Only run MDI subsetting during production builds, skip in dev server
...(command === 'build' ? [mdiSubset()] : []),
...(command === "build" ? [mdiSubset()] : []),
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => ['v-list-recognize-title'].includes(tag)
}
}
isCustomElement: (tag) => ["v-list-recognize-title"].includes(tag),
},
},
}),
vuetify({
autoImport: true
autoImport: true,
}),
webfontDl()
webfontDl(),
],
resolve: {
alias: {
mermaid: 'mermaid/dist/mermaid.js',
'@': fileURLToPath(new URL('./src', import.meta.url))
}
mermaid: "mermaid/dist/mermaid.js",
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {}
}
scss: {
api: "modern-compiler",
silenceDeprecations: ["import", "global-builtin"],
},
},
},
build: {
sourcemap: false,
chunkSizeWarningLimit: 1024 * 1024 // Set the limit to 1 MB
chunkSizeWarningLimit: 1024 * 1024, // Set the limit to 1 MB
},
optimizeDeps: {
exclude: ['vuetify'],
entries: ['./src/**/*.vue']
exclude: ["vuetify"],
entries: ["./src/**/*.vue"],
},
server: {
host: '0.0.0.0',
host: "0.0.0.0",
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:6185/',
"/api": {
target: "http://127.0.0.1:6185/",
changeOrigin: true,
ws: true
}
}
}
ws: true,
},
},
},
}));

View File

@@ -87,4 +87,4 @@
## 🎉 大功告成
此时,你的 AstrBot 和 NapCatQQ 应该已经连接成功。使用 `私聊` 的方式在 QQ 对机器人发送 `/help` 以检查是否连接成功。
此时,你的 AstrBot 应该已经成功连接 QQ 官方接口。使用 `私聊` 的方式在 QQ 对机器人发送 `/help` 以检查是否连接成功。

View File

@@ -66,6 +66,7 @@ dependencies = [
"shipyard-python-sdk>=0.2.4",
"shipyard-neo-sdk>=0.2.0",
"python-socks>=2.8.0",
"pysocks>=1.7.1",
"packaging>=24.2",
]

View File

@@ -32,6 +32,7 @@ pydub>=0.25.1
pyjwt>=2.10.1
python-telegram-bot>=22.6
qq-botpy>=1.2.1
python-socks>=2.8.0
quart>=0.20.0
readability-lxml>=0.8.4.1
silk-python>=0.2.6
@@ -45,6 +46,7 @@ wechatpy>=1.8.18
audioop-lts ; python_full_version >= '3.13'
click>=8.2.1
pypdf>=6.1.1
pysocks>=1.7.1
aiofiles>=25.1.0
rank-bm25>=0.2.2
jieba>=0.42.1

View File

@@ -1173,3 +1173,360 @@ async def test_parse_openai_completion_raises_empty_model_output_error():
await provider._parse_openai_completion(completion, tools=None)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_filters_empty_assistant_message_without_tool_calls(monkeypatch):
"""Test that empty assistant messages without tool_calls are filtered out."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": ""}, # Should be filtered
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# The empty assistant message should be filtered out
messages = captured_kwargs["messages"]
assert len(messages) == 2
assert messages[0] == {"role": "user", "content": "hello"}
assert messages[1] == {"role": "user", "content": "world"}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_filters_null_content_assistant_message_without_tool_calls(
monkeypatch,
):
"""Test that assistant messages with null content and no tool_calls are filtered."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": None}, # Should be filtered
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# The null content assistant message should be filtered out
messages = captured_kwargs["messages"]
assert len(messages) == 2
assert messages[0] == {"role": "user", "content": "hello"}
assert messages[1] == {"role": "user", "content": "world"}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_converts_empty_content_to_none_with_tool_calls(monkeypatch):
"""Test that empty content with tool_calls is converted to None (OpenAI spec)."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call-123",
"type": "function",
"function": {"name": "test", "arguments": "{}"},
}
],
},
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# The assistant message with tool_calls should be kept but content set to None
messages = captured_kwargs["messages"]
assert len(messages) == 3
assert messages[1]["role"] == "assistant"
assert messages[1]["content"] is None
assert messages[1]["tool_calls"] is not None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_keeps_valid_assistant_message_with_content(monkeypatch):
"""Test that valid assistant messages with content are kept."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "response"},
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# All messages should be kept
messages = captured_kwargs["messages"]
assert len(messages) == 3
assert messages[1] == {"role": "assistant", "content": "response"}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_keeps_assistant_message_with_tool_calls_and_none_content(
monkeypatch,
):
"""Test that assistant messages with tool_calls and None content are kept."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-123",
"type": "function",
"function": {"name": "test", "arguments": "{}"},
}
],
},
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# The assistant message with tool_calls should be kept
messages = captured_kwargs["messages"]
assert len(messages) == 3
assert messages[1]["role"] == "assistant"
assert messages[1]["content"] is None
assert messages[1]["tool_calls"] is not None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_does_not_filter_user_or_system_messages(monkeypatch):
"""Test that user and system messages are not affected by the filter."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "system", "content": ""}, # Empty system message
{"role": "user", "content": ""}, # Empty user message
{"role": "assistant", "content": ""}, # Should be filtered
{"role": "user", "content": "hello"},
],
}
await provider._query(payloads=payloads, tools=None)
# Only assistant message should be filtered
messages = captured_kwargs["messages"]
assert len(messages) == 3
assert messages[0] == {"role": "system", "content": ""}
assert messages[1] == {"role": "user", "content": ""}
assert messages[2] == {"role": "user", "content": "hello"}
finally:
await provider.terminate()

View File

@@ -0,0 +1,305 @@
"""
Unit tests for knowledge base manager resilience behavior.
Tests the following scenarios:
1. update_kb preserves old instance when re-initialization fails
2. update_kb switches instance only after new instance initializes successfully
3. _ensure_vec_db clears stale init_error after successful initialization
These tests use lazy imports and mocks to avoid circular import issues
in the astrbot core module chain.
"""
import sys
import types
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def stub_provider_manager_module():
"""Stub provider manager module to avoid circular imports in unit tests."""
original_module = sys.modules.get("astrbot.core.provider.manager")
stub_module = types.ModuleType("astrbot.core.provider.manager")
class ProviderManager: ...
setattr(stub_module, "ProviderManager", ProviderManager)
sys.modules["astrbot.core.provider.manager"] = stub_module
try:
yield
finally:
if original_module is not None:
sys.modules["astrbot.core.provider.manager"] = original_module
else:
sys.modules.pop("astrbot.core.provider.manager", None)
@pytest.fixture
def mock_provider_manager():
"""Create a mock ProviderManager."""
manager = MagicMock()
manager.get_provider_by_id = AsyncMock()
manager.acm = MagicMock()
manager.acm.default_conf = {}
return manager
@pytest.fixture
def mock_kb_db():
"""Create a mock KBSQLiteDatabase."""
db = MagicMock()
db.get_db = MagicMock()
db.list_kbs = AsyncMock(return_value=[])
db.get_kb_by_id = AsyncMock()
return db
@pytest.fixture
def mock_knowledge_base():
"""Create a mock KnowledgeBase instance."""
# Use lazy import to avoid circular import
from astrbot.core.knowledge_base.models import KnowledgeBase
kb = KnowledgeBase(
kb_name="test_kb",
description="Test knowledge base",
emoji="📚",
embedding_provider_id="test-embedding-provider",
rerank_provider_id=None,
chunk_size=512,
chunk_overlap=50,
top_k_dense=50,
top_k_sparse=50,
top_m_final=5,
)
return kb
@pytest.fixture
def mock_embedding_provider():
"""Create a mock EmbeddingProvider."""
provider = MagicMock()
provider.get_embeddings_batch = AsyncMock(return_value=[[0.1, 0.2, 0.3]])
return provider
@pytest.mark.asyncio
async def test_update_kb_preserves_old_instance_when_reinit_fails(
stub_provider_manager_module,
mock_provider_manager,
mock_kb_db,
mock_knowledge_base,
mock_embedding_provider,
):
"""
Test that update_kb preserves the old KBHelper instance when
re-initialization fails, ensuring the knowledge base remains available.
"""
# Lazy import to avoid circular import
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
# Setup: create an existing KBHelper with working vec_db
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
# Create KBHelper using __new__ to avoid __init__ side effects
old_helper = KBHelper.__new__(KBHelper)
old_helper.kb = mock_knowledge_base
old_helper.prov_mgr = mock_provider_manager
old_helper.kb_db = mock_kb_db
old_helper.kb_root_dir = "/tmp/test_kb"
old_helper.chunker = MagicMock()
old_helper.init_error = None
old_helper.vec_db = MagicMock() # Simulate existing working vec_db
old_helper.terminate = AsyncMock()
# Create KBManager and inject the existing helper
kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
kb_mgr.provider_manager = mock_provider_manager
kb_mgr.kb_db = mock_kb_db
kb_mgr.kb_insts = {mock_knowledge_base.kb_id: old_helper}
kb_mgr.retrieval_manager = MagicMock()
# Mock KBHelper creation to simulate initialization failure
with patch.object(KBHelper, "initialize", new_callable=AsyncMock) as mock_init:
# First call (for new_helper) should fail
mock_init.side_effect = Exception("Embedding provider unavailable")
# Execute update_kb with a different embedding provider
result = await kb_mgr.update_kb(
kb_id=mock_knowledge_base.kb_id,
kb_name="updated_kb",
embedding_provider_id="new-embedding-provider",
)
# Verify: the old helper should be returned, not a new one
assert result is not None
assert result is old_helper
assert kb_mgr.kb_insts[mock_knowledge_base.kb_id] is old_helper
# Verify: old helper's vec_db should still be available
assert hasattr(result, "vec_db")
assert result.vec_db is not None
# Verify: failure does not replace the existing helper state
assert result.init_error is None
assert result.kb.kb_name == "test_kb"
assert result.kb.embedding_provider_id == "test-embedding-provider"
@pytest.mark.asyncio
async def test_update_kb_switches_instance_only_after_new_reinit_success(
stub_provider_manager_module,
mock_provider_manager,
mock_kb_db,
mock_knowledge_base,
mock_embedding_provider,
):
"""
Test that update_kb only switches to the new KBHelper instance
after the new instance successfully initializes.
"""
# Lazy import to avoid circular import
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
# Setup: create an existing KBHelper
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
old_helper = KBHelper.__new__(KBHelper)
old_helper.kb = mock_knowledge_base
old_helper.prov_mgr = mock_provider_manager
old_helper.kb_db = mock_kb_db
old_helper.kb_root_dir = "/tmp/test_kb"
old_helper.chunker = MagicMock()
old_helper.init_error = None
old_helper.vec_db = MagicMock()
old_helper.terminate = AsyncMock()
kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
kb_mgr.provider_manager = mock_provider_manager
kb_mgr.kb_db = mock_kb_db
kb_mgr.kb_insts = {mock_knowledge_base.kb_id: old_helper}
kb_mgr.retrieval_manager = MagicMock()
# Mock session context for database operations
mock_session = MagicMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_db_context = MagicMock()
mock_db_context.__aenter__ = AsyncMock(return_value=mock_session)
mock_db_context.__aexit__ = AsyncMock()
mock_kb_db.get_db.return_value = mock_db_context
# Mock KBHelper.initialize to succeed
with patch.object(KBHelper, "initialize", new_callable=AsyncMock) as mock_init:
mock_init.return_value = None
# Execute update_kb
result = await kb_mgr.update_kb(
kb_id=mock_knowledge_base.kb_id,
kb_name="updated_kb",
embedding_provider_id="new-embedding-provider",
)
# Verify: a new helper should be returned
assert result is not None
assert result is not old_helper
assert result.init_error is None
assert kb_mgr.kb_insts[mock_knowledge_base.kb_id] is result
# Verify: old helper should be terminated
old_helper.terminate.assert_called_once()
@pytest.mark.asyncio
async def test_ensure_vec_db_clears_stale_init_error(
stub_provider_manager_module,
mock_provider_manager,
mock_kb_db,
mock_knowledge_base,
mock_embedding_provider,
):
"""
Test that _ensure_vec_db clears the init_error attribute
after successful initialization, removing stale error state.
"""
# Lazy import to avoid circular import
from astrbot.core.knowledge_base.kb_helper import KBHelper
# Setup: create KBHelper with stale init_error
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
helper = KBHelper.__new__(KBHelper)
helper.kb = mock_knowledge_base
helper.prov_mgr = mock_provider_manager
helper.kb_db = mock_kb_db
helper.kb_root_dir = "/tmp/test_kb"
helper.chunker = MagicMock()
helper.init_error = "Previous initialization failed"
helper.kb_dir = Path("/tmp/test_kb") / mock_knowledge_base.kb_id
helper.kb_medias_dir = helper.kb_dir / "medias" / mock_knowledge_base.kb_id
helper.kb_files_dir = helper.kb_dir / "files" / mock_knowledge_base.kb_id
# Mock FaissVecDB initialization
mock_vec_db = MagicMock()
mock_vec_db.initialize = AsyncMock()
mock_vec_db.close = AsyncMock()
with patch(
"astrbot.core.knowledge_base.kb_helper.FaissVecDB",
return_value=mock_vec_db,
):
# Execute _ensure_vec_db
await helper._ensure_vec_db()
# Verify: init_error should be cleared
assert helper.init_error is None
assert helper.vec_db is mock_vec_db
@pytest.mark.asyncio
async def test_ensure_vec_db_sets_init_error_on_failure(
stub_provider_manager_module,
mock_provider_manager,
mock_kb_db,
mock_knowledge_base,
):
"""
Test that _ensure_vec_db does NOT clear init_error when
initialization fails, preserving the error state.
"""
# Lazy import to avoid circular import
from astrbot.core.knowledge_base.kb_helper import KBHelper
# Setup: provider unavailable
mock_provider_manager.get_provider_by_id.return_value = None
helper = KBHelper.__new__(KBHelper)
helper.kb = mock_knowledge_base
helper.prov_mgr = mock_provider_manager
helper.kb_db = mock_kb_db
helper.kb_root_dir = "/tmp/test_kb"
helper.chunker = MagicMock()
helper.init_error = "Previous initialization failed"
helper.kb_dir = Path("/tmp/test_kb") / mock_knowledge_base.kb_id
helper.kb_medias_dir = helper.kb_dir / "medias" / mock_knowledge_base.kb_id
helper.kb_files_dir = helper.kb_dir / "files" / mock_knowledge_base.kb_id
# Execute _ensure_vec_db - should raise exception
try:
await helper._ensure_vec_db()
pytest.fail("Expected exception but none was raised")
except ValueError as e:
# Verify: exception should be raised
assert "无法找到" in str(e) or "未配置" in str(e)
# Verify: init_error should NOT be cleared (still has previous error)
# Note: _ensure_vec_db doesn't set init_error; that's done by the caller
assert helper.init_error is not None