Merge branch 'main' into feat/workspace
This commit is contained in:
commit
7fbefc0963
@ -5,6 +5,11 @@ SAVE_DIR=./saves
|
|||||||
SILICONFLOW_API_KEY= # 推荐使用硅基流动免费服务 https://cloud.siliconflow.cn/i/Eo5yTHGJ
|
SILICONFLOW_API_KEY= # 推荐使用硅基流动免费服务 https://cloud.siliconflow.cn/i/Eo5yTHGJ
|
||||||
TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.com/
|
TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.com/
|
||||||
|
|
||||||
|
# JWT 安全配置:首次部署请生成随机值,初始化脚本会在留空时自动生成
|
||||||
|
YUXI_ENV=development
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
YUXI_INSTANCE_ID=
|
||||||
|
|
||||||
# # 其余可选配置
|
# # 其余可选配置
|
||||||
# OPENAI_API_KEY=
|
# OPENAI_API_KEY=
|
||||||
# OPENAI_API_BASE=
|
# OPENAI_API_BASE=
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import re
|
|||||||
from fastapi import Depends, Header, HTTPException, status
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from yuxi.storage.postgres.manager import pg_manager
|
from yuxi.storage.postgres.manager import pg_manager
|
||||||
from yuxi.storage.postgres.models_business import User, APIKey
|
from yuxi.storage.postgres.models_business import APIKey, User
|
||||||
from server.utils.auth_utils import AuthUtils
|
|
||||||
from yuxi.utils.datetime_utils import utc_now_naive
|
from yuxi.utils.datetime_utils import utc_now_naive
|
||||||
|
|
||||||
|
from server.utils.auth_utils import AuthUtils
|
||||||
|
|
||||||
# 定义OAuth2密码承载器,指定token URL
|
# 定义OAuth2密码承载器,指定token URL
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
|
||||||
|
|
||||||
@ -115,10 +115,16 @@ async def get_current_user(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(select(User).filter(User.id == int(user_id)))
|
result = await db.execute(select(User).filter(User.id == int(user_id), User.is_deleted == 0))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
if user.is_login_locked():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail="登录被锁定,请稍后重试",
|
||||||
|
headers={"X-Lock-Remaining": str(user.get_remaining_lock_time())},
|
||||||
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,52 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
|
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
|
||||||
|
|
||||||
from yuxi.utils.datetime_utils import utc_now
|
from yuxi.utils.datetime_utils import utc_now
|
||||||
|
|
||||||
# JWT配置
|
# JWT配置
|
||||||
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "yuxi_know_secure_key")
|
LEGACY_JWT_SECRET_KEY = "yuxi_know_secure_key"
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
JWT_EXPIRATION = 7 * 24 * 60 * 60 # 7天过期
|
JWT_EXPIRATION = 7 * 24 * 60 * 60 # 7天过期
|
||||||
|
JWT_AUDIENCE = "yuxi-know-api"
|
||||||
PASSWORD_HASHER = PasswordHasher()
|
PASSWORD_HASHER = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_production_env() -> bool:
|
||||||
|
return os.environ.get("YUXI_ENV", "development").strip().lower() in {"prod", "production"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_dev_env(name: str, value_factory) -> str:
|
||||||
|
value = os.environ.get(name, "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
if _is_production_env():
|
||||||
|
raise ValueError(f"{name} 未配置,请在生产环境的 .env.prod 中设置持久化随机值")
|
||||||
|
|
||||||
|
value = value_factory()
|
||||||
|
os.environ[name] = value
|
||||||
|
print(f"{name} 未配置,开发环境已自动生成临时随机值,服务重启后会重新生成。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwt_secret_key() -> str:
|
||||||
|
secret_key = _get_or_create_dev_env("JWT_SECRET_KEY", lambda: secrets.token_hex(32))
|
||||||
|
if secret_key == LEGACY_JWT_SECRET_KEY:
|
||||||
|
raise ValueError("JWT_SECRET_KEY 不能使用历史默认密钥,请重新生成随机强密钥")
|
||||||
|
return secret_key
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwt_issuer() -> str:
|
||||||
|
instance_id = _get_or_create_dev_env("YUXI_INSTANCE_ID", lambda: f"instance-{secrets.token_hex(8)}")
|
||||||
|
return f"yuxi-know:{instance_id}"
|
||||||
|
|
||||||
|
|
||||||
class AuthUtils:
|
class AuthUtils:
|
||||||
"""认证工具类"""
|
"""认证工具类"""
|
||||||
|
|
||||||
@ -53,26 +83,40 @@ class AuthUtils:
|
|||||||
else:
|
else:
|
||||||
expire = utc_now() + timedelta(seconds=JWT_EXPIRATION)
|
expire = utc_now() + timedelta(seconds=JWT_EXPIRATION)
|
||||||
|
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire, "iss": _get_jwt_issuer(), "aud": JWT_AUDIENCE})
|
||||||
|
|
||||||
# 编码JWT
|
# 编码JWT
|
||||||
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, _get_jwt_secret_key(), algorithm=JWT_ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode_token(token: str) -> dict[str, Any] | None:
|
def decode_token(token: str) -> dict[str, Any] | None:
|
||||||
"""解码验证JWT令牌"""
|
"""解码验证JWT令牌"""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
_get_jwt_secret_key(),
|
||||||
|
algorithms=[JWT_ALGORITHM],
|
||||||
|
issuer=_get_jwt_issuer(),
|
||||||
|
audience=JWT_AUDIENCE,
|
||||||
|
options={"require": ["exp", "sub", "iss", "aud"]},
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
except jwt.PyJWTError:
|
except (jwt.PyJWTError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_access_token(token: str) -> dict[str, Any]:
|
def verify_access_token(token: str) -> dict[str, Any]:
|
||||||
"""验证访问令牌,如果无效则抛出异常"""
|
"""验证访问令牌,如果无效则抛出异常"""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
_get_jwt_secret_key(),
|
||||||
|
algorithms=[JWT_ALGORITHM],
|
||||||
|
issuer=_get_jwt_issuer(),
|
||||||
|
audience=JWT_AUDIENCE,
|
||||||
|
options={"require": ["exp", "sub", "iss", "aud"]},
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
raise ValueError("令牌已过期")
|
raise ValueError("令牌已过期")
|
||||||
|
|||||||
@ -25,7 +25,9 @@ async def test_user_is_locked_after_repeated_failed_logins(test_client, standard
|
|||||||
assert response.status_code == 401, response.text
|
assert response.status_code == 401, response.text
|
||||||
assert response.json()["detail"] == "用户名或密码错误"
|
assert response.json()["detail"] == "用户名或密码错误"
|
||||||
|
|
||||||
locked_response = await test_client.post("/api/auth/token", data={"username": user_id, "password": "wrong-password"})
|
locked_response = await test_client.post(
|
||||||
|
"/api/auth/token", data={"username": user_id, "password": "wrong-password"}
|
||||||
|
)
|
||||||
assert locked_response.status_code == 423, locked_response.text
|
assert locked_response.status_code == 423, locked_response.text
|
||||||
assert "X-Lock-Remaining" in locked_response.headers
|
assert "X-Lock-Remaining" in locked_response.headers
|
||||||
assert "账户已被锁定" in locked_response.json()["detail"]
|
assert "账户已被锁定" in locked_response.json()["detail"]
|
||||||
@ -73,3 +75,24 @@ async def test_invalid_token_is_rejected(test_client):
|
|||||||
headers = {"Authorization": "Bearer not-a-real-token"}
|
headers = {"Authorization": "Bearer not-a-real-token"}
|
||||||
response = await test_client.get("/api/auth/me", headers=headers)
|
response = await test_client.get("/api/auth/me", headers=headers)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deleted_user_token_is_rejected(test_client, admin_headers, standard_user):
|
||||||
|
user_id = standard_user["user"]["id"]
|
||||||
|
|
||||||
|
delete_response = await test_client.delete(f"/api/auth/users/{user_id}", headers=admin_headers)
|
||||||
|
assert delete_response.status_code == 200, delete_response.text
|
||||||
|
|
||||||
|
profile_response = await test_client.get("/api/auth/me", headers=standard_user["headers"])
|
||||||
|
assert profile_response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_locked_user_token_is_rejected(test_client, standard_user):
|
||||||
|
user_id = standard_user["user"]["user_id"]
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
await test_client.post("/api/auth/token", data={"username": user_id, "password": "wrong-password"})
|
||||||
|
|
||||||
|
profile_response = await test_client.get("/api/auth/me", headers=standard_user["headers"])
|
||||||
|
assert profile_response.status_code == 423
|
||||||
|
assert "X-Lock-Remaining" in profile_response.headers
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from server.utils.auth_utils import AuthUtils
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from yuxi.utils.datetime_utils import utc_now
|
||||||
|
|
||||||
|
from server.utils.auth_utils import JWT_ALGORITHM, JWT_AUDIENCE, LEGACY_JWT_SECRET_KEY, AuthUtils
|
||||||
|
|
||||||
|
|
||||||
def test_hash_password_uses_argon2():
|
def test_hash_password_uses_argon2():
|
||||||
@ -18,3 +24,101 @@ def test_verify_password_accepts_legacy_sha256_format():
|
|||||||
|
|
||||||
assert AuthUtils.verify_password(f"{legacy_hash}:salt", "secret-password") is True
|
assert AuthUtils.verify_password(f"{legacy_hash}:salt", "secret-password") is True
|
||||||
assert AuthUtils.verify_password(f"{legacy_hash}:salt", "wrong-password") is False
|
assert AuthUtils.verify_password(f"{legacy_hash}:salt", "wrong-password") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_contains_instance_claims(monkeypatch):
|
||||||
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
|
||||||
|
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
|
||||||
|
|
||||||
|
token = AuthUtils.create_access_token({"sub": "1"})
|
||||||
|
payload = AuthUtils.verify_access_token(token)
|
||||||
|
|
||||||
|
assert payload["sub"] == "1"
|
||||||
|
assert payload["iss"] == "yuxi-know:pytest-instance"
|
||||||
|
assert payload["aud"] == JWT_AUDIENCE
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_auto_generates_dev_secret(monkeypatch):
|
||||||
|
monkeypatch.setenv("YUXI_ENV", "development")
|
||||||
|
monkeypatch.delenv("JWT_SECRET_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
|
||||||
|
|
||||||
|
token = AuthUtils.create_access_token({"sub": "1"})
|
||||||
|
|
||||||
|
assert AuthUtils.verify_access_token(token)["sub"] == "1"
|
||||||
|
assert len(os.environ["JWT_SECRET_KEY"]) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_requires_configured_secret_in_production(monkeypatch):
|
||||||
|
monkeypatch.setenv("YUXI_ENV", "production")
|
||||||
|
monkeypatch.delenv("JWT_SECRET_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="JWT_SECRET_KEY"):
|
||||||
|
AuthUtils.create_access_token({"sub": "1"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_requires_non_default_secret(monkeypatch):
|
||||||
|
monkeypatch.setenv("JWT_SECRET_KEY", LEGACY_JWT_SECRET_KEY)
|
||||||
|
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="历史默认密钥"):
|
||||||
|
AuthUtils.create_access_token({"sub": "1"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_auto_generates_dev_instance_id(monkeypatch):
|
||||||
|
monkeypatch.setenv("YUXI_ENV", "development")
|
||||||
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
|
||||||
|
monkeypatch.delenv("YUXI_INSTANCE_ID", raising=False)
|
||||||
|
|
||||||
|
token = AuthUtils.create_access_token({"sub": "1"})
|
||||||
|
|
||||||
|
assert AuthUtils.verify_access_token(token)["iss"].startswith("yuxi-know:instance-")
|
||||||
|
assert os.environ["YUXI_INSTANCE_ID"].startswith("instance-")
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_requires_instance_id_in_production(monkeypatch):
|
||||||
|
monkeypatch.setenv("YUXI_ENV", "production")
|
||||||
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
|
||||||
|
monkeypatch.delenv("YUXI_INSTANCE_ID", raising=False)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="YUXI_INSTANCE_ID"):
|
||||||
|
AuthUtils.create_access_token({"sub": "1"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_access_token_rejects_wrong_issuer(monkeypatch):
|
||||||
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
|
||||||
|
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
|
||||||
|
token = jwt.encode(
|
||||||
|
{"sub": "1", "exp": utc_now() + timedelta(minutes=5), "iss": "yuxi-know:other", "aud": JWT_AUDIENCE},
|
||||||
|
"test-secret-key-with-enough-randomness",
|
||||||
|
algorithm=JWT_ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert AuthUtils.decode_token(token) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_access_token_rejects_wrong_audience(monkeypatch):
|
||||||
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
|
||||||
|
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
|
||||||
|
token = jwt.encode(
|
||||||
|
{"sub": "1", "exp": utc_now() + timedelta(minutes=5), "iss": "yuxi-know:pytest-instance", "aud": "other-api"},
|
||||||
|
"test-secret-key-with-enough-randomness",
|
||||||
|
algorithm=JWT_ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="无效的令牌"):
|
||||||
|
AuthUtils.verify_access_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_access_token_requires_claims(monkeypatch):
|
||||||
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
|
||||||
|
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
|
||||||
|
token = jwt.encode(
|
||||||
|
{"sub": "1", "exp": utc_now() + timedelta(minutes=5)},
|
||||||
|
"test-secret-key-with-enough-randomness",
|
||||||
|
algorithm=JWT_ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="无效的令牌"):
|
||||||
|
AuthUtils.verify_access_token(token)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ x-api-worker-env: &api-worker-env
|
|||||||
HOST_IP: ${HOST_IP:-}
|
HOST_IP: ${HOST_IP:-}
|
||||||
LITE_MODE: ${LITE_MODE:-}
|
LITE_MODE: ${LITE_MODE:-}
|
||||||
RUNNING_IN_DOCKER: "true"
|
RUNNING_IN_DOCKER: "true"
|
||||||
|
YUXI_ENV: production
|
||||||
MODEL_DIR_IN_DOCKER: /models
|
MODEL_DIR_IN_DOCKER: /models
|
||||||
PYTHONPATH: /app/package
|
PYTHONPATH: /app/package
|
||||||
POSTGRES_URL: ${POSTGRES_URL:-postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-yuxi_know}}
|
POSTGRES_URL: ${POSTGRES_URL:-postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-yuxi_know}}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ x-api-worker-env: &api-worker-env
|
|||||||
HOST_IP: ${HOST_IP:-}
|
HOST_IP: ${HOST_IP:-}
|
||||||
LITE_MODE: ${LITE_MODE:-false}
|
LITE_MODE: ${LITE_MODE:-false}
|
||||||
RUNNING_IN_DOCKER: "true"
|
RUNNING_IN_DOCKER: "true"
|
||||||
|
YUXI_ENV: development
|
||||||
MODEL_DIR_IN_DOCKER: /models
|
MODEL_DIR_IN_DOCKER: /models
|
||||||
# DBs and other services
|
# DBs and other services
|
||||||
POSTGRES_URL: ${POSTGRES_URL:-postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-yuxi_know}}
|
POSTGRES_URL: ${POSTGRES_URL:-postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-yuxi_know}}
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
<!-- 0.6.2 的内容请放在这里 -->
|
<!-- 0.6.2 的内容请放在这里 -->
|
||||||
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
|
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
|
||||||
|
- 加固 JWT 鉴权安全:移除历史默认密钥回退,初始化脚本支持生成并持久化 `JWT_SECRET_KEY` 与 `YUXI_INSTANCE_ID`,签发和验证令牌时校验 `iss/aud`,并在鉴权阶段拒绝已删除或登录锁定用户继续使用旧令牌访问系统。
|
||||||
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
|
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
|
||||||
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
|
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
|
||||||
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
||||||
|
|||||||
@ -2,12 +2,49 @@
|
|||||||
# This script helps set up the environment for the Yuxi project
|
# This script helps set up the environment for the Yuxi project
|
||||||
# Note: API keys will be visible during input - use with care
|
# Note: API keys will be visible during input - use with care
|
||||||
|
|
||||||
|
function New-RandomHex($ByteCount) {
|
||||||
|
$bytes = [byte[]]::new($ByteCount)
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
||||||
|
return -join ($bytes | ForEach-Object { $_.ToString("x2") })
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-EnvValue($Name) {
|
||||||
|
return [bool](Select-String -Path ".env" -Pattern "^$Name=.+" -Quiet)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-JwtEnv {
|
||||||
|
if ((Test-EnvValue "JWT_SECRET_KEY") -and (Test-EnvValue "YUXI_INSTANCE_ID")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "JWT security settings are missing in .env." -ForegroundColor Yellow
|
||||||
|
$JWT_SECRET_KEY = Read-Host "Please enter your JWT_SECRET_KEY (press Enter to auto-generate)"
|
||||||
|
if ([string]::IsNullOrEmpty($JWT_SECRET_KEY)) {
|
||||||
|
$JWT_SECRET_KEY = New-RandomHex 32
|
||||||
|
Write-Host "Generated JWT_SECRET_KEY and saved it to .env." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
$YUXI_INSTANCE_ID = Read-Host "Please enter your YUXI_INSTANCE_ID (press Enter to auto-generate)"
|
||||||
|
if ([string]::IsNullOrEmpty($YUXI_INSTANCE_ID)) {
|
||||||
|
$YUXI_INSTANCE_ID = "instance-$(New-RandomHex 8)"
|
||||||
|
Write-Host "Generated YUXI_INSTANCE_ID and saved it to .env." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
@"
|
||||||
|
|
||||||
|
# JWT security settings
|
||||||
|
JWT_SECRET_KEY=$JWT_SECRET_KEY
|
||||||
|
YUXI_INSTANCE_ID=$YUXI_INSTANCE_ID
|
||||||
|
"@ | Add-Content -Path ".env" -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "🚀 Initializing Yuxi project..." -ForegroundColor Cyan
|
Write-Host "🚀 Initializing Yuxi project..." -ForegroundColor Cyan
|
||||||
Write-Host "==================================" -ForegroundColor Cyan
|
Write-Host "==================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
# Check if .env file exists
|
# Check if .env file exists
|
||||||
if (Test-Path ".env") {
|
if (Test-Path ".env") {
|
||||||
Write-Host "✅ .env file already exists. Skipping environment setup." -ForegroundColor Green
|
Write-Host "✅ .env file already exists. Skipping environment setup." -ForegroundColor Green
|
||||||
|
Ensure-JwtEnv
|
||||||
} else {
|
} else {
|
||||||
Write-Host "📝 .env file not found. Let's set up your environment variables." -ForegroundColor Yellow
|
Write-Host "📝 .env file not found. Let's set up your environment variables." -ForegroundColor Yellow
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
@ -32,6 +69,20 @@ if (Test-Path ".env") {
|
|||||||
|
|
||||||
$TAVILY_API_KEY = Read-Host "Please enter your TAVILY_API_KEY (press Enter to skip)"
|
$TAVILY_API_KEY = Read-Host "Please enter your TAVILY_API_KEY (press Enter to skip)"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "JWT security settings" -ForegroundColor Yellow
|
||||||
|
$JWT_SECRET_KEY = Read-Host "Please enter your JWT_SECRET_KEY (press Enter to auto-generate)"
|
||||||
|
if ([string]::IsNullOrEmpty($JWT_SECRET_KEY)) {
|
||||||
|
$JWT_SECRET_KEY = New-RandomHex 32
|
||||||
|
Write-Host "Generated JWT_SECRET_KEY and saved it to .env." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
$YUXI_INSTANCE_ID = Read-Host "Please enter your YUXI_INSTANCE_ID (press Enter to auto-generate)"
|
||||||
|
if ([string]::IsNullOrEmpty($YUXI_INSTANCE_ID)) {
|
||||||
|
$YUXI_INSTANCE_ID = "instance-$(New-RandomHex 8)"
|
||||||
|
Write-Host "Generated YUXI_INSTANCE_ID and saved it to .env." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
# Create .env file
|
# Create .env file
|
||||||
$envContent = @"
|
$envContent = @"
|
||||||
# SiliconFlow API Key (required)
|
# SiliconFlow API Key (required)
|
||||||
@ -44,12 +95,21 @@ SILICONFLOW_API_KEY=$apiKey
|
|||||||
$envContent += "`nTAVILY_API_KEY=$TAVILY_API_KEY"
|
$envContent += "`nTAVILY_API_KEY=$TAVILY_API_KEY"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$envContent += @"
|
||||||
|
|
||||||
|
# JWT security settings
|
||||||
|
JWT_SECRET_KEY=$JWT_SECRET_KEY
|
||||||
|
YUXI_INSTANCE_ID=$YUXI_INSTANCE_ID
|
||||||
|
"@
|
||||||
|
|
||||||
$envContent | Out-File -FilePath ".env" -Encoding UTF8
|
$envContent | Out-File -FilePath ".env" -Encoding UTF8
|
||||||
Write-Host "✅ .env file created successfully!" -ForegroundColor Green
|
Write-Host "✅ .env file created successfully!" -ForegroundColor Green
|
||||||
|
|
||||||
# Clear the variables from memory
|
# Clear the variables from memory
|
||||||
Remove-Variable -Name "apiKey" -ErrorAction SilentlyContinue
|
Remove-Variable -Name "apiKey" -ErrorAction SilentlyContinue
|
||||||
Remove-Variable -Name "TAVILY_API_KEY" -ErrorAction SilentlyContinue
|
Remove-Variable -Name "TAVILY_API_KEY" -ErrorAction SilentlyContinue
|
||||||
|
Remove-Variable -Name "JWT_SECRET_KEY" -ErrorAction SilentlyContinue
|
||||||
|
Remove-Variable -Name "YUXI_INSTANCE_ID" -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@ -5,12 +5,49 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
generate_hex() {
|
||||||
|
local length="$1"
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
openssl rand -hex "$length"
|
||||||
|
else
|
||||||
|
tr -dc 'a-f0-9' < /dev/urandom | head -c $((length * 2))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_jwt_env() {
|
||||||
|
if grep -Eq '^JWT_SECRET_KEY=.+' .env && grep -Eq '^YUXI_INSTANCE_ID=.+' .env; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "JWT security settings are missing in .env."
|
||||||
|
read -s -p "Please enter your JWT_SECRET_KEY (press Enter to auto-generate): " JWT_SECRET_KEY
|
||||||
|
echo ""
|
||||||
|
if [ -z "$JWT_SECRET_KEY" ]; then
|
||||||
|
JWT_SECRET_KEY=$(generate_hex 32)
|
||||||
|
echo "Generated JWT_SECRET_KEY and saved it to .env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Please enter your YUXI_INSTANCE_ID (press Enter to auto-generate): " YUXI_INSTANCE_ID
|
||||||
|
if [ -z "$YUXI_INSTANCE_ID" ]; then
|
||||||
|
YUXI_INSTANCE_ID="instance-$(generate_hex 8)"
|
||||||
|
echo "Generated YUXI_INSTANCE_ID and saved it to .env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> .env << EOF
|
||||||
|
|
||||||
|
# JWT security settings
|
||||||
|
JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||||
|
YUXI_INSTANCE_ID=${YUXI_INSTANCE_ID}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
echo "🚀 Initializing Yuxi project..."
|
echo "🚀 Initializing Yuxi project..."
|
||||||
echo "=================================="
|
echo "=================================="
|
||||||
|
|
||||||
# Check if .env file exists
|
# Check if .env file exists
|
||||||
if [ -f ".env" ]; then
|
if [ -f ".env" ]; then
|
||||||
echo "✅ .env file already exists. Skipping environment setup."
|
echo "✅ .env file already exists. Skipping environment setup."
|
||||||
|
ensure_jwt_env
|
||||||
else
|
else
|
||||||
echo "📝 .env file not found. Let's set up your environment variables."
|
echo "📝 .env file not found. Let's set up your environment variables."
|
||||||
echo ""
|
echo ""
|
||||||
@ -34,6 +71,21 @@ else
|
|||||||
echo "Get your API key from: https://app.tavily.com/"
|
echo "Get your API key from: https://app.tavily.com/"
|
||||||
read -p "Please enter your TAVILY_API_KEY (press Enter to skip): " TAVILY_API_KEY
|
read -p "Please enter your TAVILY_API_KEY (press Enter to skip): " TAVILY_API_KEY
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "JWT security settings"
|
||||||
|
read -s -p "Please enter your JWT_SECRET_KEY (press Enter to auto-generate): " JWT_SECRET_KEY
|
||||||
|
echo ""
|
||||||
|
if [ -z "$JWT_SECRET_KEY" ]; then
|
||||||
|
JWT_SECRET_KEY=$(generate_hex 32)
|
||||||
|
echo "Generated JWT_SECRET_KEY and saved it to .env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Please enter your YUXI_INSTANCE_ID (press Enter to auto-generate): " YUXI_INSTANCE_ID
|
||||||
|
if [ -z "$YUXI_INSTANCE_ID" ]; then
|
||||||
|
YUXI_INSTANCE_ID="instance-$(generate_hex 8)"
|
||||||
|
echo "Generated YUXI_INSTANCE_ID and saved it to .env."
|
||||||
|
fi
|
||||||
|
|
||||||
# Create .env file
|
# Create .env file
|
||||||
cat > .env << EOF
|
cat > .env << EOF
|
||||||
# SiliconFlow API Key (required)
|
# SiliconFlow API Key (required)
|
||||||
@ -46,6 +98,13 @@ EOF
|
|||||||
echo "TAVILY_API_KEY=${TAVILY_API_KEY}" >> .env
|
echo "TAVILY_API_KEY=${TAVILY_API_KEY}" >> .env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
cat >> .env << EOF
|
||||||
|
|
||||||
|
# JWT security settings
|
||||||
|
JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||||
|
YUXI_INSTANCE_ID=${YUXI_INSTANCE_ID}
|
||||||
|
EOF
|
||||||
|
|
||||||
echo "✅ .env file created successfully!"
|
echo "✅ .env file created successfully!"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user