Compare commits
11 Commits
fcd13d8d9f
...
99c30d9534
| Author | SHA1 | Date | |
|---|---|---|---|
| 99c30d9534 | |||
| 9824bc2d6c | |||
| fad41ce94a | |||
| 6966ced359 | |||
| 0f63ac82f4 | |||
| c552f71e28 | |||
| d3749817b0 | |||
| cdde7e3bc9 | |||
| 672adf9287 | |||
| 0e6828722c | |||
| 79f25a3a74 |
@@ -13,6 +13,7 @@ from app.agents.prompts import (
|
|||||||
ANALYST_SYSTEM_PROMPT,
|
ANALYST_SYSTEM_PROMPT,
|
||||||
)
|
)
|
||||||
from app.agents.tools import ALL_TOOLS
|
from app.agents.tools import ALL_TOOLS
|
||||||
|
from app.agents.skill_registry import build_skill_context
|
||||||
from app.services.llm_service import get_llm
|
from app.services.llm_service import get_llm
|
||||||
|
|
||||||
|
|
||||||
@@ -69,8 +70,13 @@ async def planner_node(state: AgentState) -> AgentState:
|
|||||||
user_msgs = _filter_user_messages(state["messages"])
|
user_msgs = _filter_user_messages(state["messages"])
|
||||||
user_query = user_msgs[-1].content if user_msgs else ""
|
user_query = user_msgs[-1].content if user_msgs else ""
|
||||||
|
|
||||||
|
system_msgs = [SystemMessage(content=PLANNER_SYSTEM_PROMPT)]
|
||||||
|
skill_ctx = build_skill_context("planner")
|
||||||
|
if skill_ctx:
|
||||||
|
system_msgs.append(SystemMessage(content=skill_ctx))
|
||||||
|
|
||||||
response = await llm.invoke(
|
response = await llm.invoke(
|
||||||
[SystemMessage(content=PLANNER_SYSTEM_PROMPT), HumanMessage(content=f"用户请求: {user_query}")]
|
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")]
|
||||||
)
|
)
|
||||||
|
|
||||||
plan_text = response.content
|
plan_text = response.content
|
||||||
@@ -92,8 +98,13 @@ async def executor_node(state: AgentState) -> AgentState:
|
|||||||
user_msgs = _filter_user_messages(state["messages"])
|
user_msgs = _filter_user_messages(state["messages"])
|
||||||
user_query = user_msgs[-1].content if user_msgs else ""
|
user_query = user_msgs[-1].content if user_msgs else ""
|
||||||
|
|
||||||
|
system_msgs = [SystemMessage(content=EXECUTOR_SYSTEM_PROMPT)]
|
||||||
|
skill_ctx = build_skill_context("executor")
|
||||||
|
if skill_ctx:
|
||||||
|
system_msgs.append(SystemMessage(content=skill_ctx))
|
||||||
|
|
||||||
response = await llm.bind_tools(ALL_TOOLS).invoke(
|
response = await llm.bind_tools(ALL_TOOLS).invoke(
|
||||||
[SystemMessage(content=EXECUTOR_SYSTEM_PROMPT), HumanMessage(content=f"用户请求: {user_query}")]
|
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")]
|
||||||
)
|
)
|
||||||
|
|
||||||
tool_calls = getattr(response, "tool_calls", None) or []
|
tool_calls = getattr(response, "tool_calls", None) or []
|
||||||
@@ -131,8 +142,13 @@ async def librarian_node(state: AgentState) -> AgentState:
|
|||||||
user_msgs = _filter_user_messages(state["messages"])
|
user_msgs = _filter_user_messages(state["messages"])
|
||||||
user_query = user_msgs[-1].content if user_msgs else ""
|
user_query = user_msgs[-1].content if user_msgs else ""
|
||||||
|
|
||||||
|
system_msgs = [SystemMessage(content=LIBRARIAN_SYSTEM_PROMPT)]
|
||||||
|
skill_ctx = build_skill_context("librarian")
|
||||||
|
if skill_ctx:
|
||||||
|
system_msgs.append(SystemMessage(content=skill_ctx))
|
||||||
|
|
||||||
response = await llm.bind_tools(ALL_TOOLS).invoke(
|
response = await llm.bind_tools(ALL_TOOLS).invoke(
|
||||||
[SystemMessage(content=LIBRARIAN_SYSTEM_PROMPT), HumanMessage(content=f"用户请求: {user_query}")]
|
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")]
|
||||||
)
|
)
|
||||||
|
|
||||||
tool_calls = getattr(response, "tool_calls", None) or []
|
tool_calls = getattr(response, "tool_calls", None) or []
|
||||||
@@ -171,8 +187,13 @@ async def analyst_node(state: AgentState) -> AgentState:
|
|||||||
user_msgs = _filter_user_messages(state["messages"])
|
user_msgs = _filter_user_messages(state["messages"])
|
||||||
user_query = user_msgs[-1].content if user_msgs else ""
|
user_query = user_msgs[-1].content if user_msgs else ""
|
||||||
|
|
||||||
|
system_msgs = [SystemMessage(content=ANALYST_SYSTEM_PROMPT)]
|
||||||
|
skill_ctx = build_skill_context("analyst")
|
||||||
|
if skill_ctx:
|
||||||
|
system_msgs.append(SystemMessage(content=skill_ctx))
|
||||||
|
|
||||||
response = await llm.bind_tools(ALL_TOOLS).invoke(
|
response = await llm.bind_tools(ALL_TOOLS).invoke(
|
||||||
[SystemMessage(content=ANALYST_SYSTEM_PROMPT), HumanMessage(content=f"用户请求: {user_query}")]
|
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")]
|
||||||
)
|
)
|
||||||
|
|
||||||
tool_calls = getattr(response, "tool_calls", None) or []
|
tool_calls = getattr(response, "tool_calls", None) or []
|
||||||
|
|||||||
46
backend/app/agents/skill_registry.py
Normal file
46
backend/app/agents/skill_registry.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Skill Registry - Agent 运行时加载 Skills
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import async_session
|
||||||
|
from app.services.skill_service import SkillService
|
||||||
|
|
||||||
|
# 缓存:agent_type -> list[Skill]
|
||||||
|
_skill_cache: dict[str, list] = {}
|
||||||
|
|
||||||
|
async def load_skills_for_agent(agent_type: str, force_reload: bool = False) -> list:
|
||||||
|
"""加载指定 Agent 类型的可用 Skills"""
|
||||||
|
if not force_reload and agent_type in _skill_cache:
|
||||||
|
return _skill_cache[agent_type]
|
||||||
|
async with async_session() as db:
|
||||||
|
svc = SkillService(db)
|
||||||
|
skills = await svc.get_by_agent_type(agent_type)
|
||||||
|
_skill_cache[agent_type] = skills
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def get_skills_for_agent(agent_type: str) -> list:
|
||||||
|
"""同步接口:返回缓存的 Skills(供 Agent 节点调用)"""
|
||||||
|
return _skill_cache.get(agent_type, [])
|
||||||
|
|
||||||
|
def build_skill_context(agent_type: str) -> str:
|
||||||
|
"""
|
||||||
|
构建 Skill 上下文,供注入到 Agent 系统提示
|
||||||
|
格式:Skill 名称 + 描述 + 工具列表
|
||||||
|
"""
|
||||||
|
skills = get_skills_for_agent(agent_type)
|
||||||
|
if not skills:
|
||||||
|
return ""
|
||||||
|
lines = ["\n\n【可用的 Skills】"]
|
||||||
|
for s in skills:
|
||||||
|
tools_str = ", ".join(s.tools) if s.tools else "无"
|
||||||
|
lines.append(f"""
|
||||||
|
## {s.name}
|
||||||
|
- 描述: {s.description or '无'}
|
||||||
|
- 工具: {tools_str}
|
||||||
|
- 指令: {s.instructions[:200]}...""" if len(s.instructions) > 200 else f"- 指令: {s.instructions}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def clear_cache():
|
||||||
|
"""清除缓存(配置变更时调用)"""
|
||||||
|
global _skill_cache
|
||||||
|
_skill_cache = {}
|
||||||
@@ -13,6 +13,7 @@ from app.routers import (
|
|||||||
todo_router,
|
todo_router,
|
||||||
settings_router,
|
settings_router,
|
||||||
folder_router,
|
folder_router,
|
||||||
|
skill_router,
|
||||||
)
|
)
|
||||||
from app.routers.scheduler import router as scheduler_router
|
from app.routers.scheduler import router as scheduler_router
|
||||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
||||||
@@ -59,6 +60,7 @@ app.include_router(agent_router)
|
|||||||
app.include_router(todo_router)
|
app.include_router(todo_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(folder_router)
|
app.include_router(folder_router)
|
||||||
|
app.include_router(skill_router)
|
||||||
app.include_router(scheduler_router)
|
app.include_router(scheduler_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
22
backend/app/models/skill.py
Normal file
22
backend/app/models/skill.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy import Column, String, Text, Boolean, JSON, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Skill(BaseModel):
|
||||||
|
__tablename__ = "skills"
|
||||||
|
|
||||||
|
name = Column(String(100), nullable=False, unique=True, index=True)
|
||||||
|
description = Column(Text, nullable=True) # 供 LLM 理解用途
|
||||||
|
instructions = Column(Text, nullable=False) # Agent 执行时的指令模板
|
||||||
|
agent_type = Column(String(50), nullable=False, index=True) # master/planner/executor/librarian/analyst
|
||||||
|
tools = Column(JSON, default=list) # 引用的工具名称列表
|
||||||
|
required_context = Column(JSON, default=list) # 需要的前置数据
|
||||||
|
output_format = Column(Text, nullable=True) # 输出格式要求
|
||||||
|
visibility = Column(String(20), default="private") # private/team/market
|
||||||
|
team_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
|
owner = relationship("User", foreign_keys=[owner_id])
|
||||||
|
team = relationship("User", foreign_keys=[team_id])
|
||||||
@@ -8,3 +8,4 @@ from app.routers.agent import router as agent_router
|
|||||||
from app.routers.todo import router as todo_router
|
from app.routers.todo import router as todo_router
|
||||||
from app.routers.settings import router as settings_router
|
from app.routers.settings import router as settings_router
|
||||||
from app.routers.folder import router as folder_router
|
from app.routers.folder import router as folder_router
|
||||||
|
from app.routers.skill import router as skill_router
|
||||||
|
|||||||
112
backend/app/routers/skill.py
Normal file
112
backend/app/routers/skill.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.skill import Skill
|
||||||
|
from app.models.user import User
|
||||||
|
from app.routers.auth import get_current_user
|
||||||
|
from app.schemas.skill import SkillCreate, SkillOut, SkillUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/skills", tags=["Skill"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SkillOut, status_code=201)
|
||||||
|
async def create_skill(
|
||||||
|
data: SkillCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
skill = Skill(
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
instructions=data.instructions,
|
||||||
|
agent_type=data.agent_type,
|
||||||
|
tools=data.tools,
|
||||||
|
required_context=data.required_context,
|
||||||
|
output_format=data.output_format,
|
||||||
|
visibility=data.visibility,
|
||||||
|
team_id=data.team_id,
|
||||||
|
is_active=data.is_active,
|
||||||
|
owner_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(skill)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(skill)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[SkillOut])
|
||||||
|
async def list_skills(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Skill).where(Skill.owner_id == current_user.id).order_by(Skill.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{skill_id}", response_model=SkillOut)
|
||||||
|
async def get_skill(
|
||||||
|
skill_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Skill).where(Skill.id == skill_id))
|
||||||
|
skill = result.scalar_one_or_none()
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill not found")
|
||||||
|
return skill
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{skill_id}", response_model=SkillOut)
|
||||||
|
async def update_skill(
|
||||||
|
skill_id: str,
|
||||||
|
data: SkillUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Skill).where(Skill.id == skill_id))
|
||||||
|
skill = result.scalar_one_or_none()
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill not found")
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
skill.name = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
skill.description = data.description
|
||||||
|
if data.instructions is not None:
|
||||||
|
skill.instructions = data.instructions
|
||||||
|
if data.agent_type is not None:
|
||||||
|
skill.agent_type = data.agent_type
|
||||||
|
if data.tools is not None:
|
||||||
|
skill.tools = data.tools
|
||||||
|
if data.required_context is not None:
|
||||||
|
skill.required_context = data.required_context
|
||||||
|
if data.output_format is not None:
|
||||||
|
skill.output_format = data.output_format
|
||||||
|
if data.visibility is not None:
|
||||||
|
skill.visibility = data.visibility
|
||||||
|
if data.team_id is not None:
|
||||||
|
skill.team_id = data.team_id
|
||||||
|
if data.is_active is not None:
|
||||||
|
skill.is_active = data.is_active
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(skill)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{skill_id}", status_code=204)
|
||||||
|
async def delete_skill(
|
||||||
|
skill_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Skill).where(Skill.id == skill_id))
|
||||||
|
skill = result.scalar_one_or_none()
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill not found")
|
||||||
|
|
||||||
|
await db.delete(skill)
|
||||||
|
await db.commit()
|
||||||
48
backend/app/schemas/skill.py
Normal file
48
backend/app/schemas/skill.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class SkillCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
instructions: str
|
||||||
|
agent_type: str # master/planner/executor/librarian/analyst
|
||||||
|
tools: list[str] = []
|
||||||
|
required_context: list[str] = []
|
||||||
|
output_format: Optional[str] = None
|
||||||
|
visibility: str = "private"
|
||||||
|
team_id: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class SkillUpdate(BaseModel):
|
||||||
|
# All fields Optional
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
instructions: Optional[str] = None
|
||||||
|
agent_type: Optional[str] = None
|
||||||
|
tools: Optional[list[str]] = None
|
||||||
|
required_context: Optional[list[str]] = None
|
||||||
|
output_format: Optional[str] = None
|
||||||
|
visibility: Optional[str] = None
|
||||||
|
team_id: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
instructions: str
|
||||||
|
agent_type: str
|
||||||
|
tools: list[str]
|
||||||
|
required_context: list[str]
|
||||||
|
output_format: Optional[str]
|
||||||
|
visibility: str
|
||||||
|
team_id: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
owner_id: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
133
backend/app/services/skill_service.py
Normal file
133
backend/app/services/skill_service.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
Skill Service - 技能管理服务层
|
||||||
|
负责技能的创建、查询、更新、删除等操作
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, or_
|
||||||
|
from app.models.skill import Skill
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class SkillService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create(self, user_id: str, data: dict) -> Skill:
|
||||||
|
"""创建新技能"""
|
||||||
|
skill = Skill(
|
||||||
|
owner_id=user_id,
|
||||||
|
name=data.get("name"),
|
||||||
|
description=data.get("description"),
|
||||||
|
instructions=data.get("instructions"),
|
||||||
|
agent_type=data.get("agent_type"),
|
||||||
|
tools=data.get("tools", []),
|
||||||
|
required_context=data.get("required_context", []),
|
||||||
|
output_format=data.get("output_format"),
|
||||||
|
visibility=data.get("visibility", "private"),
|
||||||
|
team_id=data.get("team_id"),
|
||||||
|
is_active=data.get("is_active", True),
|
||||||
|
)
|
||||||
|
self.db.add(skill)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(skill)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
async def get_by_id(self, skill_id: str) -> Optional[Skill]:
|
||||||
|
"""根据ID获取技能"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Skill).where(Skill.id == skill_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_for_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
agent_type: Optional[str] = None,
|
||||||
|
visibility: Optional[str] = None,
|
||||||
|
) -> list[Skill]:
|
||||||
|
"""
|
||||||
|
列出用户可访问的技能:自己的 + 市场的 + 团队的
|
||||||
|
"""
|
||||||
|
# 查询条件:自己的 或者 市场公开的 或者 团队的
|
||||||
|
conditions = [
|
||||||
|
Skill.owner_id == user_id,
|
||||||
|
Skill.visibility == "market",
|
||||||
|
Skill.team_id == user_id,
|
||||||
|
]
|
||||||
|
|
||||||
|
# 如果提供了 agent_type 过滤
|
||||||
|
if agent_type:
|
||||||
|
conditions.append(Skill.agent_type == agent_type)
|
||||||
|
|
||||||
|
# 如果提供了 visibility 过滤
|
||||||
|
if visibility:
|
||||||
|
conditions.append(Skill.visibility == visibility)
|
||||||
|
|
||||||
|
query = select(Skill).where(
|
||||||
|
and_(
|
||||||
|
or_(*conditions),
|
||||||
|
Skill.is_active == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def update(self, skill_id: str, user_id: str, data: dict) -> Optional[Skill]:
|
||||||
|
"""更新技能(仅所有者可更新)"""
|
||||||
|
skill = await self.get_by_id(skill_id)
|
||||||
|
if not skill:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查是否是所有者
|
||||||
|
if skill.owner_id != user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 更新字段
|
||||||
|
update_fields = [
|
||||||
|
"name", "description", "instructions", "agent_type",
|
||||||
|
"tools", "required_context", "output_format", "visibility",
|
||||||
|
"team_id", "is_active"
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in update_fields:
|
||||||
|
if field in data:
|
||||||
|
setattr(skill, field, data[field])
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(skill)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
async def delete(self, skill_id: str, user_id: str) -> bool:
|
||||||
|
"""删除技能(仅所有者可删除)"""
|
||||||
|
skill = await self.get_by_id(skill_id)
|
||||||
|
if not skill:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查是否是所有者
|
||||||
|
if skill.owner_id != user_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self.db.delete(skill)
|
||||||
|
await self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_by_agent_type(self, agent_type: str) -> list[Skill]:
|
||||||
|
"""
|
||||||
|
获取指定 agent_type 的技能(用于 agent 运行时:市场 + 私有)
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Skill).where(
|
||||||
|
and_(
|
||||||
|
Skill.agent_type == agent_type,
|
||||||
|
Skill.is_active == True,
|
||||||
|
or_(
|
||||||
|
Skill.visibility == "market",
|
||||||
|
Skill.visibility == "private"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
# LLM Config Table UI Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 将 Settings 页面的 LLM 模型配置从卡片列表改为表格行内编辑形式
|
||||||
|
|
||||||
|
**Architecture:** 使用 Vue 3 Composition API,在 SettingsView.vue 内实现 4 个 LLM 类型区的表格组件,每种类型独立成区,支持行内展开编辑、测试连接、保存操作。
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3, TypeScript, Lucide Icons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── views/
|
||||||
|
│ └── SettingsView.vue # 重构:表格行内编辑 UI(所有逻辑内聚在此文件)
|
||||||
|
└── components/settings/
|
||||||
|
└── LLMTableRow.vue # 表格行组件(抽取以保持 SettingsView.vue 简洁)
|
||||||
|
|
||||||
|
backend/app/
|
||||||
|
├── routers/settings.py # 确认测试 API 存在
|
||||||
|
└── services/settings_service.py # 确认无需修改
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 验证后端测试 API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/routers/settings.py`
|
||||||
|
- Modify: `backend/app/services/settings_service.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 确认 `/api/settings/llm/test` 端点存在**
|
||||||
|
|
||||||
|
检查 `backend/app/routers/settings.py` 中是否有 `POST /api/settings/llm/test` 路由。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 确认 `test_llm_connection` 函数存在**
|
||||||
|
|
||||||
|
检查 `backend/app/services/settings_service.py` 中是否有 `test_llm_connection` 函数。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -1
|
||||||
|
# 如果确认后端无需修改:
|
||||||
|
echo "Backend API already supports the new UI"
|
||||||
|
# 如果发现问题需要修复:
|
||||||
|
# git add backend/app/routers/settings.py
|
||||||
|
# git commit -m "fix(settings): ensure test LLM API exists"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 创建 LLMTableRow 组件
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/components/settings/LLMTableRow.vue`
|
||||||
|
- Modify: `frontend/src/views/SettingsView.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 LLMTableRow.vue 组件**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Eye, EyeOff, Play, ChevronDown, ChevronRight, Trash2 } from 'lucide-vue-next'
|
||||||
|
import type { LLMModelConfig } from '@/api/settings'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
model: LLMModelConfig
|
||||||
|
isExpanded: boolean
|
||||||
|
isNew?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'toggle'): void
|
||||||
|
(e: 'update', data: LLMModelConfig): void
|
||||||
|
(e: 'delete'): void
|
||||||
|
(e: 'test', data: LLMModelConfig): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showApiKey = ref(false)
|
||||||
|
|
||||||
|
const status = computed(() => {
|
||||||
|
if (!props.model.api_key || !props.model.model) return 'empty'
|
||||||
|
if (props.model.enabled) return 'available'
|
||||||
|
return 'unavailable'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusConfig = computed(() => ({
|
||||||
|
available: { icon: '●', color: '#10b981', label: '可用' },
|
||||||
|
unavailable: { icon: '○', color: '#6b7280', label: '不可用' },
|
||||||
|
empty: { icon: '⚠', color: '#ef4444', label: '未配置' }
|
||||||
|
}[status.value]))
|
||||||
|
|
||||||
|
function onProviderChange() {
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
ollama: 'http://localhost:11434',
|
||||||
|
openai: 'https://api.openai.com/v1',
|
||||||
|
claude: 'https://api.anthropic.com',
|
||||||
|
deepseek: 'https://api.deepseek.com/v1'
|
||||||
|
}
|
||||||
|
if (!props.model.base_url || Object.values(defaults).includes(props.model.base_url)) {
|
||||||
|
emit('update', { ...props.model, base_url: defaults[props.model.provider] || '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="table-row" :class="{ expanded: isExpanded, 'is-new': isNew }">
|
||||||
|
<!-- 表格行(可点击展开) -->
|
||||||
|
<div class="row-summary" @click="emit('toggle')">
|
||||||
|
<div class="cell cell-toggle">
|
||||||
|
<ChevronDown v-if="isExpanded" :size="14" />
|
||||||
|
<ChevronRight v-else :size="14" />
|
||||||
|
</div>
|
||||||
|
<div class="cell cell-name">{{ model.name || '未命名' }}</div>
|
||||||
|
<div class="cell cell-provider">{{ model.provider }}</div>
|
||||||
|
<div class="cell cell-model">{{ model.model || '-' }}</div>
|
||||||
|
<div class="cell cell-status" :style="{ color: statusConfig.color }">
|
||||||
|
{{ statusConfig.icon }} {{ statusConfig.label }}
|
||||||
|
</div>
|
||||||
|
<div class="cell cell-actions" @click.stop>
|
||||||
|
<button class="icon-btn danger" @click="emit('delete')" title="删除">
|
||||||
|
<Trash2 :size="12" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开的详情面板 -->
|
||||||
|
<div v-if="isExpanded" class="expand-panel">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>// PROVIDER</label>
|
||||||
|
<select v-model="model.provider" @change="onProviderChange">
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="claude">Claude</option>
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>// MODEL</label>
|
||||||
|
<input v-model="model.model" type="text" placeholder="gpt-4o" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>// BASE URL</label>
|
||||||
|
<input v-model="model.base_url" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>// API KEY</label>
|
||||||
|
<div class="input-with-toggle">
|
||||||
|
<input
|
||||||
|
v-model="model.api_key"
|
||||||
|
:type="showApiKey ? 'text' : 'password'"
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
<button @click="showApiKey = !showApiKey">
|
||||||
|
<Eye v-if="!showApiKey" :size="14" />
|
||||||
|
<EyeOff v-else :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="test-btn" @click="emit('test', model)">
|
||||||
|
<Play :size="12" /> 测试连接
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="save-btn"
|
||||||
|
:disabled="status !== 'available'"
|
||||||
|
@click="emit('update', model)"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button class="cancel-btn" @click="emit('toggle')">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-row {
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-summary:hover {
|
||||||
|
background: rgba(0,245,212,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-toggle { width: 30px; }
|
||||||
|
.cell-name { flex: 1; min-width: 120px; }
|
||||||
|
.cell-provider { width: 80px; }
|
||||||
|
.cell-model { width: 120px; }
|
||||||
|
.cell-status { width: 80px; }
|
||||||
|
.cell-actions { width: 40px; text-align: right; }
|
||||||
|
|
||||||
|
.expand-panel {
|
||||||
|
padding: 14px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-toggle input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-toggle button {
|
||||||
|
width: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-btn, .save-btn, .cancel-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(0,245,212,0.3);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
background: rgba(0,245,212,0.1);
|
||||||
|
border: 1px solid rgba(0,245,212,0.3);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger:hover {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 SettingsView.vue 中引入 LLMTableRow**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/settings/LLMTableRow.vue
|
||||||
|
git commit -m "feat(settings): create LLMTableRow component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 重构 SettingsView.vue 主体结构
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/SettingsView.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 清理原有 LLM 配置相关代码**
|
||||||
|
|
||||||
|
删除原有的 `llmConfig` 卡片列表 UI(template 中的 `.model-list`、`.model-item` 等部分),保留 profile 和 scheduler 配置部分。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 添加 LLM 配置状态管理**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// LLM 配置
|
||||||
|
const llmConfig = ref<LLMConfig>({
|
||||||
|
chat: [],
|
||||||
|
vlm: [],
|
||||||
|
embedding: [],
|
||||||
|
rerank: []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 原始配置(用于比较变更)
|
||||||
|
const originalLlmConfig = ref<LLMConfig>({ chat: [], vlm: [], embedding: [], rerank: [] })
|
||||||
|
|
||||||
|
// 展开的行
|
||||||
|
const expandedRow = ref<string | null>(null) // 'chat-0', 'vlm-0' 等
|
||||||
|
|
||||||
|
// 当前正在编辑的模型快照(用于取消时恢复)
|
||||||
|
const editingSnapshot = ref<{ type: string; index: number; data: LLMModelConfig } | null>(null)
|
||||||
|
|
||||||
|
// 必填警告
|
||||||
|
const showRequiredWarning = computed(() => {
|
||||||
|
return llmConfig.value.chat.length === 0 ||
|
||||||
|
llmConfig.value.embedding.length === 0 ||
|
||||||
|
llmConfig.value.rerank.length === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 行标识
|
||||||
|
function getRowKey(type: string, index: number): string {
|
||||||
|
return `${type}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换行展开
|
||||||
|
function toggleRow(type: string, index: number, model: LLMModelConfig) {
|
||||||
|
const key = getRowKey(type, index)
|
||||||
|
if (expandedRow.value === key) {
|
||||||
|
expandedRow.value = null
|
||||||
|
editingSnapshot.value = null
|
||||||
|
} else {
|
||||||
|
// 保存快照用于取消
|
||||||
|
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) }
|
||||||
|
expandedRow.value = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消编辑
|
||||||
|
function cancelEdit(type: string, index: number) {
|
||||||
|
if (editingSnapshot.value && editingSnapshot.value.type === type && editingSnapshot.value.index === index) {
|
||||||
|
// 恢复原始数据
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index] = editingSnapshot.value.data
|
||||||
|
}
|
||||||
|
expandedRow.value = null
|
||||||
|
editingSnapshot.value = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现添加模型**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function addModel(type: string) {
|
||||||
|
if (!llmConfig.value[type as keyof LLMConfig]) {
|
||||||
|
llmConfig.value[type as keyof LLMConfig] = []
|
||||||
|
}
|
||||||
|
// embedding/rerank 最多 1 个
|
||||||
|
if ((type === 'embedding' || type === 'rerank') &&
|
||||||
|
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
|
||||||
|
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newModel: LLMModelConfig = {
|
||||||
|
name: `${type.toUpperCase()}-${Date.now()}`,
|
||||||
|
provider: 'openai',
|
||||||
|
model: type === 'chat' ? 'gpt-4o' : type === 'vlm' ? 'gpt-4o' : type === 'embedding' ? 'text-embedding-3-small' : 'bge-reranker-v2',
|
||||||
|
base_url: 'https://api.openai.com/v1',
|
||||||
|
api_key: '',
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
|
||||||
|
// 自动展开新添加的行
|
||||||
|
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
|
||||||
|
expandedRow.value = getRowKey(type, newIndex)
|
||||||
|
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 实现删除模型**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function removeModel(type: string, index: number) {
|
||||||
|
// embedding/rerank 为知识库必填,至少保留 1 个
|
||||||
|
if ((type === 'embedding' || type === 'rerank') &&
|
||||||
|
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
|
||||||
|
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
|
||||||
|
expandedRow.value = null
|
||||||
|
editingSnapshot.value = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 实现更新模型**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function updateModel(type: string, index: number, model: LLMModelConfig) {
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index] = model
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 实现测试连接**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function testModel(type: string, index: number, model: LLMModelConfig) {
|
||||||
|
try {
|
||||||
|
const res = await settingsApi.testLLM({ type: type as any, ...model })
|
||||||
|
if (res.data.success) {
|
||||||
|
// 测试通过,标记为可用
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
|
||||||
|
showToast('连接成功')
|
||||||
|
} else {
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
||||||
|
showToast(`连接失败: ${res.data.error}`, 'error')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
||||||
|
showToast('测试连接失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: 实现保存模型**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function saveModel(type: string, index: number) {
|
||||||
|
const key = getRowKey(type, index)
|
||||||
|
savingModel.value = key
|
||||||
|
try {
|
||||||
|
await settingsApi.updateLLM(llmConfig.value)
|
||||||
|
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
||||||
|
expandedRow.value = null
|
||||||
|
editingSnapshot.value = null
|
||||||
|
showToast('保存成功')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
|
||||||
|
showToast(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
savingModel.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: 编写 template 的 LLM Config Section**
|
||||||
|
|
||||||
|
在 Profile section 之后,Scheduler section 之前添加:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- LLM Config Section -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">// LLM CONFIGURATION</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 必填警告 -->
|
||||||
|
<div v-if="showRequiredWarning" class="warning-bar">
|
||||||
|
⚠ chat / embedding / rerank 为知识库必填,请确保已配置
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Section -->
|
||||||
|
<div class="llm-type-section">
|
||||||
|
<div class="llm-type-header">
|
||||||
|
<span class="llm-type-title">CHAT</span>
|
||||||
|
<button class="add-btn" @click="addModel('chat')">
|
||||||
|
<Plus :size="12" /> 添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="llmConfig.chat && llmConfig.chat.length > 0" class="model-table">
|
||||||
|
<div v-for="(model, index) in llmConfig.chat" :key="index">
|
||||||
|
<LLMTableRow
|
||||||
|
:model="model"
|
||||||
|
:is-expanded="expandedRow === getRowKey('chat', index)"
|
||||||
|
@toggle="toggleRow('chat', index, model)"
|
||||||
|
@update="(m) => updateModel('chat', index, m)"
|
||||||
|
@delete="removeModel('chat', index)"
|
||||||
|
@test="(m) => testModel('chat', index, m)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">暂无 chat 模型配置</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VLM Section -->
|
||||||
|
<div class="llm-type-section">
|
||||||
|
<div class="llm-type-header">
|
||||||
|
<span class="llm-type-title">VLM <span class="optional-tag">(可选)</span></span>
|
||||||
|
<button class="add-btn" @click="addModel('vlm')">
|
||||||
|
<Plus :size="12" /> 添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="llmConfig.vlm && llmConfig.vlm.length > 0" class="model-table">
|
||||||
|
<div v-for="(model, index) in llmConfig.vlm" :key="index">
|
||||||
|
<LLMTableRow
|
||||||
|
:model="model"
|
||||||
|
:is-expanded="expandedRow === getRowKey('vlm', index)"
|
||||||
|
@toggle="toggleRow('vlm', index, model)"
|
||||||
|
@update="(m) => updateModel('vlm', index, m)"
|
||||||
|
@delete="removeModel('vlm', index)"
|
||||||
|
@test="(m) => testModel('vlm', index, m)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">暂无 vlm 模型配置</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embedding Section -->
|
||||||
|
<div class="llm-type-section">
|
||||||
|
<div class="llm-type-header">
|
||||||
|
<span class="llm-type-title">EMBEDDING <span class="required-tag">(知识库)</span></span>
|
||||||
|
<button class="add-btn" @click="addModel('embedding')">
|
||||||
|
<Plus :size="12" /> 添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="llmConfig.embedding && llmConfig.embedding.length > 0" class="model-table">
|
||||||
|
<div v-for="(model, index) in llmConfig.embedding" :key="index">
|
||||||
|
<LLMTableRow
|
||||||
|
:model="model"
|
||||||
|
:is-expanded="expandedRow === getRowKey('embedding', index)"
|
||||||
|
@toggle="toggleRow('embedding', index, model)"
|
||||||
|
@update="(m) => updateModel('embedding', index, m)"
|
||||||
|
@delete="removeModel('embedding', index)"
|
||||||
|
@test="(m) => testModel('embedding', index, m)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">暂无 embedding 模型配置</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rerank Section -->
|
||||||
|
<div class="llm-type-section">
|
||||||
|
<div class="llm-type-header">
|
||||||
|
<span class="llm-type-title">RERANK <span class="required-tag">(知识库)</span></span>
|
||||||
|
<button class="add-btn" @click="addModel('rerank')">
|
||||||
|
<Plus :size="12" /> 添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="llmConfig.rerank && llmConfig.rerank.length > 0" class="model-table">
|
||||||
|
<div v-for="(model, index) in llmConfig.rerank" :key="index">
|
||||||
|
<LLMTableRow
|
||||||
|
:model="model"
|
||||||
|
:is-expanded="expandedRow === getRowKey('rerank', index)"
|
||||||
|
@toggle="toggleRow('rerank', index, model)"
|
||||||
|
@update="(m) => updateModel('rerank', index, m)"
|
||||||
|
@delete="removeModel('rerank', index)"
|
||||||
|
@test="(m) => testModel('rerank', index, m)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">暂无 rerank 模型配置</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: 添加相关样式**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* LLM Type Section */
|
||||||
|
.llm-type-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-type-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-type-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-tag {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-tag {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--accent-red);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning Bar */
|
||||||
|
.warning-bar {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--accent-red);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Model Table */
|
||||||
|
.model-table {
|
||||||
|
/* 表格容器 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px dashed var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: 更新 loadSettings 函数**
|
||||||
|
|
||||||
|
确保 LLM 配置正确加载:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function loadSettings() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await settingsApi.get()
|
||||||
|
profile.value = {
|
||||||
|
email: res.data.profile.email,
|
||||||
|
full_name: res.data.profile.full_name || '',
|
||||||
|
created_at: res.data.profile.created_at
|
||||||
|
}
|
||||||
|
originalProfile.value = { ...profile.value }
|
||||||
|
|
||||||
|
// 加载 LLM 配置
|
||||||
|
if (res.data.llm_config) {
|
||||||
|
llmConfig.value = {
|
||||||
|
chat: res.data.llm_config.chat || [],
|
||||||
|
vlm: res.data.llm_config.vlm || [],
|
||||||
|
embedding: res.data.llm_config.embedding || [],
|
||||||
|
rerank: res.data.llm_config.rerank || []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
|
||||||
|
}
|
||||||
|
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
||||||
|
|
||||||
|
if (res.data.scheduler_config && Object.keys(res.data.scheduler_config).length > 0) {
|
||||||
|
schedulerConfig.value = res.data.scheduler_config as SchedulerConfig
|
||||||
|
}
|
||||||
|
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载设置失败', e)
|
||||||
|
showToast('加载设置失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 11: 注册组件**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
||||||
|
// 在 components 中注册
|
||||||
|
components: { LLMTableRow }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 12: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/views/SettingsView.vue
|
||||||
|
git commit -m "feat(settings): refactor LLM config to table inline-edit UI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 测试和验证
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `frontend/src/views/SettingsView.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 手动测试流程**
|
||||||
|
|
||||||
|
1. 打开 Settings 页面
|
||||||
|
2. 确认 chat/embedding/rerank 必填警告(如果为空)
|
||||||
|
3. 添加新模型,点击 [+] 按钮
|
||||||
|
4. 填写模型信息,点击"测试连接"
|
||||||
|
5. 测试通过后,"保存"按钮可用
|
||||||
|
6. 保存成功,刷新页面确认数据持久化
|
||||||
|
7. 点击"取消"验证表单数据恢复
|
||||||
|
|
||||||
|
- [ ] **Step 2: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(settings): complete LLM config table UI implementation"
|
||||||
|
```
|
||||||
55
frontend/src/api/skill.ts
Normal file
55
frontend/src/api/skill.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
export interface Skill {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
instructions: string
|
||||||
|
agent_type: string
|
||||||
|
tools: string[]
|
||||||
|
required_context: string[]
|
||||||
|
output_format: string | null
|
||||||
|
visibility: 'private' | 'team' | 'market'
|
||||||
|
team_id: string | null
|
||||||
|
is_active: boolean
|
||||||
|
owner_id: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillCreate {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
instructions: string
|
||||||
|
agent_type: string
|
||||||
|
tools?: string[]
|
||||||
|
required_context?: string[]
|
||||||
|
output_format?: string
|
||||||
|
visibility?: 'private' | 'team' | 'market'
|
||||||
|
team_id?: string
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillUpdate {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
instructions?: string
|
||||||
|
agent_type?: string
|
||||||
|
tools?: string[]
|
||||||
|
required_context?: string[]
|
||||||
|
output_format?: string
|
||||||
|
visibility?: 'private' | 'team' | 'market'
|
||||||
|
team_id?: string
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skillApi = {
|
||||||
|
list: (params?: { agent_type?: string; visibility?: string }): Promise<AxiosResponse<Skill[]>> => {
|
||||||
|
return api.get('/api/skills', { params })
|
||||||
|
},
|
||||||
|
get: (id: string): Promise<AxiosResponse<Skill>> => api.get(`/api/skills/${id}`),
|
||||||
|
create: (data: SkillCreate): Promise<AxiosResponse<Skill>> => api.post('/api/skills', data),
|
||||||
|
update: (id: string, data: SkillUpdate): Promise<AxiosResponse<Skill>> => api.put(`/api/skills/${id}`, data),
|
||||||
|
delete: (id: string): Promise<AxiosResponse<void>> => api.delete(`/api/skills/${id}`),
|
||||||
|
}
|
||||||
@@ -10,11 +10,12 @@ const auth = useAuthStore()
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
|
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
|
||||||
{ name: '智能链路', path: '/agents', icon: Bot },
|
{ name: '智能链路', path: '/agents', icon: Bot },
|
||||||
|
{ name: 'Skill 市场', path: '/skills', icon: Bot },
|
||||||
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
|
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
|
||||||
{ name: '关系图谱', path: '/graph', icon: Network },
|
{ name: '知识大脑', path: '/graph', icon: Network },
|
||||||
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
|
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
|
||||||
{ name: '事务栈', path: '/todo', icon: CheckSquare },
|
{ name: '任务调度', path: '/todo', icon: CheckSquare },
|
||||||
{ name: '交互广场', path: '/forum', icon: MessageSquare },
|
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
|
||||||
{ name: '数据舱', path: '/stats', icon: Activity },
|
{ name: '数据舱', path: '/stats', icon: Activity },
|
||||||
{ name: '系统设置', path: '/settings', icon: Settings },
|
{ name: '系统设置', path: '/settings', icon: Settings },
|
||||||
]
|
]
|
||||||
|
|||||||
278
frontend/src/components/settings/LLMTableRow.vue
Normal file
278
frontend/src/components/settings/LLMTableRow.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Eye, EyeOff, Play, ChevronDown, ChevronRight, Trash2 } from 'lucide-vue-next'
|
||||||
|
import type { LLMModelConfig } from '@/api/settings'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
model: LLMModelConfig
|
||||||
|
isExpanded: boolean
|
||||||
|
isNew?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'toggle'): void
|
||||||
|
(e: 'update', data: LLMModelConfig): void
|
||||||
|
(e: 'delete'): void
|
||||||
|
(e: 'test', data: LLMModelConfig): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showApiKey = ref(false)
|
||||||
|
const editingModel = ref<LLMModelConfig>({ ...props.model })
|
||||||
|
|
||||||
|
// Reinitialize editing model when expanding (handles editing different rows)
|
||||||
|
watch(() => props.isExpanded, (expanded, wasExpanded) => {
|
||||||
|
if (expanded && !wasExpanded) {
|
||||||
|
editingModel.value = { ...props.model }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = computed(() => {
|
||||||
|
if (!props.model.api_key || !props.model.model) return 'empty'
|
||||||
|
if (props.model.enabled) return 'available'
|
||||||
|
return 'unavailable'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusConfig = computed(() => ({
|
||||||
|
available: { icon: '●', color: '#10b981', label: '可用' },
|
||||||
|
unavailable: { icon: '○', color: '#6b7280', label: '不可用' },
|
||||||
|
empty: { icon: '⚠', color: '#ef4444', label: '未配置' }
|
||||||
|
}[status.value]))
|
||||||
|
|
||||||
|
function onProviderChange() {
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
ollama: 'http://localhost:11434',
|
||||||
|
openai: 'https://api.openai.com/v1',
|
||||||
|
claude: 'https://api.anthropic.com',
|
||||||
|
deepseek: 'https://api.deepseek.com/v1'
|
||||||
|
}
|
||||||
|
if (!editingModel.value.base_url || Object.values(defaults).includes(editingModel.value.base_url)) {
|
||||||
|
editingModel.value.base_url = defaults[editingModel.value.provider] || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="table-row" :class="{ expanded: isExpanded, 'is-new': isNew }">
|
||||||
|
<!-- 表格行(可点击展开) -->
|
||||||
|
<div class="row-summary" @click="emit('toggle')">
|
||||||
|
<div class="cell cell-toggle">
|
||||||
|
<ChevronDown v-if="isExpanded" :size="14" />
|
||||||
|
<ChevronRight v-else :size="14" />
|
||||||
|
</div>
|
||||||
|
<div class="cell cell-name">{{ model.name || '未命名' }}</div>
|
||||||
|
<div class="cell cell-provider">{{ model.provider }}</div>
|
||||||
|
<div class="cell cell-model">{{ model.model || '-' }}</div>
|
||||||
|
<div class="cell cell-status" :style="{ color: statusConfig.color }">
|
||||||
|
{{ statusConfig.icon }} {{ statusConfig.label }}
|
||||||
|
</div>
|
||||||
|
<div class="cell cell-actions" @click.stop>
|
||||||
|
<button class="icon-btn danger" @click="emit('delete')" title="删除">
|
||||||
|
<Trash2 :size="12" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开的详情面板 -->
|
||||||
|
<div v-if="isExpanded" class="expand-panel">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>// PROVIDER</label>
|
||||||
|
<select v-model="editingModel.provider" @change="onProviderChange">
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="claude">Claude</option>
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>// MODEL</label>
|
||||||
|
<input v-model="editingModel.model" type="text" placeholder="gpt-4o" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>// BASE URL</label>
|
||||||
|
<input v-model="editingModel.base_url" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>// API KEY</label>
|
||||||
|
<div class="input-with-toggle">
|
||||||
|
<input
|
||||||
|
v-model="editingModel.api_key"
|
||||||
|
:type="showApiKey ? 'text' : 'password'"
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
<button @click="showApiKey = !showApiKey">
|
||||||
|
<Eye v-if="!showApiKey" :size="14" />
|
||||||
|
<EyeOff v-else :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="test-btn" @click="emit('test', editingModel)">
|
||||||
|
<Play :size="12" /> 测试连接
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="save-btn"
|
||||||
|
:disabled="status !== 'available'"
|
||||||
|
@click="emit('update', editingModel)"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button class="cancel-btn" @click="emit('toggle')">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-row {
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-summary:hover {
|
||||||
|
background: rgba(0,245,212,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-toggle { width: 30px; }
|
||||||
|
.cell-name { flex: 1; min-width: 120px; }
|
||||||
|
.cell-provider { width: 80px; }
|
||||||
|
.cell-model { width: 120px; }
|
||||||
|
.cell-status { width: 80px; }
|
||||||
|
.cell-actions { width: 40px; text-align: right; }
|
||||||
|
|
||||||
|
.expand-panel {
|
||||||
|
padding: 14px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-toggle input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-toggle button {
|
||||||
|
width: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-btn, .save-btn, .cancel-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(0,245,212,0.3);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
background: rgba(0,245,212,0.1);
|
||||||
|
border: 1px solid rgba(0,245,212,0.3);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger:hover {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -54,6 +54,11 @@ const router = createRouter({
|
|||||||
name: 'stats',
|
name: 'stats',
|
||||||
component: () => import('@/views/StatsView.vue'),
|
component: () => import('@/views/StatsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'skills',
|
||||||
|
name: 'skills',
|
||||||
|
component: () => import('@/views/SkillView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'todo',
|
path: 'todo',
|
||||||
name: 'todo',
|
name: 'todo',
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig, type LLMProvider } from '@/api/settings'
|
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig, type LLMProvider } from '@/api/settings'
|
||||||
import { Save, Eye, EyeOff, Play, RotateCcw, Plus, Trash2, Copy, X } from 'lucide-vue-next'
|
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
||||||
|
import { Save, Eye, EyeOff, Play, RotateCcw, Plus, Trash2, X, Check } from 'lucide-vue-next'
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const showApiKey = ref<Record<string, boolean>>({})
|
const showApiKey = ref<Record<string, boolean>>({})
|
||||||
|
const savingModel = ref<string | null>(null) // 当前正在保存的模型 key
|
||||||
|
const modelSaveSuccess = ref<string | null>(null) // 刚刚保存成功的模型 key
|
||||||
const toast = ref<{ show: boolean; message: string; type: 'success' | 'error' }>({
|
const toast = ref<{ show: boolean; message: string; type: 'success' | 'error' }>({
|
||||||
show: false,
|
show: false,
|
||||||
message: '',
|
message: '',
|
||||||
@@ -58,6 +61,19 @@ const isSchedulerDirty = computed(() => {
|
|||||||
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
|
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 检查单个模型是否有未保存的更改
|
||||||
|
function isModelDirty(type: string, index: number): boolean {
|
||||||
|
const original = originalLlmConfig.value[type as keyof LLMConfig]?.[index]
|
||||||
|
const current = llmConfig.value[type as keyof LLMConfig]?.[index]
|
||||||
|
if (!original || !current) return false
|
||||||
|
return JSON.stringify(original) !== JSON.stringify(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型唯一标识
|
||||||
|
function getModelKey(type: string, index: number): string {
|
||||||
|
return `${type}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
// 创建空的模型配置
|
// 创建空的模型配置
|
||||||
function createEmptyModel(type: string): LLMModelConfig {
|
function createEmptyModel(type: string): LLMModelConfig {
|
||||||
return {
|
return {
|
||||||
@@ -162,6 +178,32 @@ async function saveLLM() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存单个模型
|
||||||
|
async function saveModel(type: string, index: number) {
|
||||||
|
const key = getModelKey(type, index)
|
||||||
|
savingModel.value = key
|
||||||
|
try {
|
||||||
|
// 发送完整的配置(包含该类型的所有模型)
|
||||||
|
await settingsApi.updateLLM(llmConfig.value)
|
||||||
|
|
||||||
|
// 更新原始配置(深拷贝当前完整配置)
|
||||||
|
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
||||||
|
|
||||||
|
// 显示成功状态
|
||||||
|
modelSaveSuccess.value = key
|
||||||
|
setTimeout(() => {
|
||||||
|
if (modelSaveSuccess.value === key) {
|
||||||
|
modelSaveSuccess.value = null
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
|
||||||
|
showToast(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
savingModel.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 测试 LLM 连接
|
// 测试 LLM 连接
|
||||||
async function testLLM(type: string, config: LLMModelConfig) {
|
async function testLLM(type: string, config: LLMModelConfig) {
|
||||||
try {
|
try {
|
||||||
@@ -333,8 +375,21 @@ onMounted(loadSettings)
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="model-actions">
|
<div class="model-actions">
|
||||||
<button class="icon-btn" @click="duplicateModel(type, index)" title="复制">
|
<button
|
||||||
<Copy :size="12" />
|
class="icon-btn"
|
||||||
|
:class="{
|
||||||
|
'has-changes': isModelDirty(type, index),
|
||||||
|
'is-saving': savingModel === getModelKey(type, index),
|
||||||
|
'is-saved': modelSaveSuccess === getModelKey(type, index)
|
||||||
|
}"
|
||||||
|
@click="saveModel(type, index)"
|
||||||
|
:title="isModelDirty(type, index) ? '保存更改' : '无更改'"
|
||||||
|
:disabled="savingModel === getModelKey(type, index)"
|
||||||
|
>
|
||||||
|
<div v-if="savingModel === getModelKey(type, index)" class="btn-spinner-sm"></div>
|
||||||
|
<Check v-else-if="modelSaveSuccess === getModelKey(type, index)" :size="12" />
|
||||||
|
<Save v-else :size="12" />
|
||||||
|
<span v-if="isModelDirty(type, index) && modelSaveSuccess !== getModelKey(type, index)" class="unsaved-dot"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn danger" @click="removeModel(type, index)" title="删除">
|
<button class="icon-btn danger" @click="removeModel(type, index)" title="删除">
|
||||||
<Trash2 :size="12" />
|
<Trash2 :size="12" />
|
||||||
@@ -756,6 +811,46 @@ onMounted(loadSettings)
|
|||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 保存按钮状态 */
|
||||||
|
.icon-btn.has-changes {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.is-saving {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.is-saved {
|
||||||
|
border-color: #10b981;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsaved-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--accent-red);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-spinner-sm {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--border-mid);
|
||||||
|
border-top-color: var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.model-footer {
|
.model-footer {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
|
|||||||
911
frontend/src/views/SkillView.vue
Normal file
911
frontend/src/views/SkillView.vue
Normal file
@@ -0,0 +1,911 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { skillApi, type Skill, type SkillCreate } from '@/api/skill'
|
||||||
|
import { Bot, Plus, Edit2, Trash2, Eye, EyeOff, Copy, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
// Available agent types and tools
|
||||||
|
const AGENT_TYPES = ['general', 'coder', 'analyst', 'planner', 'executor', 'librarian']
|
||||||
|
const AVAILABLE_TOOLS = ['file_operations', 'web_search', 'code_execution', 'database', 'api_calls', 'shell', 'git']
|
||||||
|
const VISIBILITY_OPTIONS = ['private', 'team', 'market'] as const
|
||||||
|
|
||||||
|
// State
|
||||||
|
const skills = ref<Skill[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const modalOpen = ref(false)
|
||||||
|
const editingSkill = ref<Skill | null>(null)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const form = ref<SkillCreate>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
instructions: '',
|
||||||
|
agent_type: 'general',
|
||||||
|
tools: [],
|
||||||
|
visibility: 'private',
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
instructions: '',
|
||||||
|
agent_type: 'general',
|
||||||
|
tools: [],
|
||||||
|
visibility: 'private',
|
||||||
|
is_active: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open create modal
|
||||||
|
function openCreateModal() {
|
||||||
|
editingSkill.value = null
|
||||||
|
resetForm()
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
function openEditModal(skill: Skill) {
|
||||||
|
editingSkill.value = skill
|
||||||
|
form.value = {
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description || '',
|
||||||
|
instructions: skill.instructions,
|
||||||
|
agent_type: skill.agent_type,
|
||||||
|
tools: [...skill.tools],
|
||||||
|
visibility: skill.visibility,
|
||||||
|
is_active: skill.is_active,
|
||||||
|
}
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen.value = false
|
||||||
|
editingSkill.value = null
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch skills
|
||||||
|
async function fetchSkills() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await skillApi.list()
|
||||||
|
skills.value = res.data
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch skills', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create skill
|
||||||
|
async function createSkill() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await skillApi.create(form.value)
|
||||||
|
skills.value.push(res.data)
|
||||||
|
closeModal()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create skill', e)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update skill
|
||||||
|
async function updateSkill() {
|
||||||
|
if (!editingSkill.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await skillApi.update(editingSkill.value.id, form.value)
|
||||||
|
const idx = skills.value.findIndex(s => s.id === editingSkill.value!.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
skills.value[idx] = res.data
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update skill', e)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete skill
|
||||||
|
async function deleteSkill(skill: Skill) {
|
||||||
|
if (!confirm(`Delete skill "${skill.name}"?`)) return
|
||||||
|
try {
|
||||||
|
await skillApi.delete(skill.id)
|
||||||
|
skills.value = skills.value.filter(s => s.id !== skill.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete skill', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle active
|
||||||
|
async function toggleActive(skill: Skill) {
|
||||||
|
try {
|
||||||
|
const res = await skillApi.update(skill.id, { is_active: !skill.is_active })
|
||||||
|
const idx = skills.value.findIndex(s => s.id === skill.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
skills.value[idx] = res.data
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to toggle skill active state', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy skill to clipboard
|
||||||
|
function copySkill(skill: Skill) {
|
||||||
|
const skillText = JSON.stringify({
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
instructions: skill.instructions,
|
||||||
|
agent_type: skill.agent_type,
|
||||||
|
tools: skill.tools,
|
||||||
|
visibility: skill.visibility,
|
||||||
|
}, null, 2)
|
||||||
|
navigator.clipboard.writeText(skillText).then(() => {
|
||||||
|
// Could add toast notification here
|
||||||
|
}).catch(e => {
|
||||||
|
console.error('Failed to copy skill', e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle tool selection
|
||||||
|
function toggleTool(tool: string) {
|
||||||
|
const idx = form.value.tools?.indexOf(tool) ?? -1
|
||||||
|
if (idx === -1) {
|
||||||
|
form.value.tools = [...(form.value.tools || []), tool]
|
||||||
|
} else {
|
||||||
|
form.value.tools = form.value.tools?.filter(t => t !== tool) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchSkills)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="skill-view scanlines">
|
||||||
|
<!-- Background -->
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="bg-glow"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="view-header">
|
||||||
|
<div class="header-title">
|
||||||
|
<span class="title-bracket">[</span>
|
||||||
|
<span class="title-text">SKILL MANAGEMENT</span>
|
||||||
|
<span class="title-bracket">]</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-icon" @click="fetchSkills" :class="{ spinning: loading }" title="Refresh">
|
||||||
|
<Copy :size="14" />
|
||||||
|
</button>
|
||||||
|
<button class="btn-add" @click="openCreateModal">
|
||||||
|
<Plus :size="14" />
|
||||||
|
<span>New Skill</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="loading-overlay">
|
||||||
|
<div class="loading-text">LOADING...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skills List -->
|
||||||
|
<div v-else-if="skills.length > 0" class="skills-grid">
|
||||||
|
<div
|
||||||
|
v-for="skill in skills"
|
||||||
|
:key="skill.id"
|
||||||
|
class="skill-card holo-card"
|
||||||
|
:class="{ inactive: !skill.is_active }"
|
||||||
|
>
|
||||||
|
<div class="skill-header">
|
||||||
|
<div class="skill-title">
|
||||||
|
<Bot :size="14" class="skill-icon" />
|
||||||
|
<span class="skill-name">{{ skill.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge agent-badge">{{ skill.agent_type }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="skill-description">{{ skill.description || 'No description' }}</p>
|
||||||
|
|
||||||
|
<div v-if="skill.tools && skill.tools.length > 0" class="skill-tools">
|
||||||
|
<span v-for="tool in skill.tools" :key="tool" class="tool-tag">{{ tool }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="skill-footer">
|
||||||
|
<div class="skill-meta">
|
||||||
|
<span class="visibility-badge" :class="skill.visibility">{{ skill.visibility }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="skill-actions">
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
:class="{ active: skill.is_active }"
|
||||||
|
@click="toggleActive(skill)"
|
||||||
|
:title="skill.is_active ? 'Disable' : 'Enable'"
|
||||||
|
>
|
||||||
|
<Eye v-if="skill.is_active" :size="14" />
|
||||||
|
<EyeOff v-else :size="14" />
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" @click="copySkill(skill)" title="Copy">
|
||||||
|
<Copy :size="14" />
|
||||||
|
</button>
|
||||||
|
<button class="action-btn edit" @click="openEditModal(skill)" title="Edit">
|
||||||
|
<Edit2 :size="14" />
|
||||||
|
</button>
|
||||||
|
<button class="action-btn delete" @click="deleteSkill(skill)" title="Delete">
|
||||||
|
<Trash2 :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<Bot :size="48" class="empty-icon" />
|
||||||
|
<p class="empty-text">No skills found</p>
|
||||||
|
<button class="btn-add" @click="openCreateModal">
|
||||||
|
<Plus :size="14" />
|
||||||
|
<span>Create your first skill</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<Transition :css="false" @enter="animateIn" @leave="animateOut">
|
||||||
|
<div v-if="modalOpen" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">{{ editingSkill ? '// EDIT SKILL' : '// NEW SKILL' }}</span>
|
||||||
|
<button class="btn-close" @click="closeModal"><X :size="16" /></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">// NAME</label>
|
||||||
|
<input v-model="form.name" type="text" class="form-input" placeholder="Skill name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">// DESCRIPTION</label>
|
||||||
|
<textarea v-model="form.description" class="form-textarea" rows="2" placeholder="Describe what this skill does..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">// AGENT TYPE</label>
|
||||||
|
<select v-model="form.agent_type" class="form-select">
|
||||||
|
<option v-for="type in AGENT_TYPES" :key="type" :value="type">{{ type }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">// VISIBILITY</label>
|
||||||
|
<select v-model="form.visibility" class="form-select">
|
||||||
|
<option v-for="vis in VISIBILITY_OPTIONS" :key="vis" :value="vis">{{ vis }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">// TOOLS</label>
|
||||||
|
<div class="tools-grid">
|
||||||
|
<label v-for="tool in AVAILABLE_TOOLS" :key="tool" class="tool-checkbox">
|
||||||
|
<input type="checkbox" :checked="form.tools?.includes(tool)" @change="toggleTool(tool)" />
|
||||||
|
<span class="checkbox-custom"></span>
|
||||||
|
<span>{{ tool }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<label class="form-label">// INSTRUCTIONS</label>
|
||||||
|
<textarea v-model="form.instructions" class="form-textarea code-textarea" rows="8" placeholder="Enter skill instructions..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="closeModal">Cancel</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
@click="editingSkill ? updateSkill() : createSkill()"
|
||||||
|
:disabled="saving || !form.name || !form.instructions"
|
||||||
|
>
|
||||||
|
<span v-if="saving" class="btn-loader"></span>
|
||||||
|
{{ saving ? 'Saving...' : (editingSkill ? 'Update' : 'Create') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.skill-view {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-void);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.view-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
background: rgba(5,8,16,0.6);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bracket {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
box-shadow: var(--glow-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.spinning svg {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(0,245,212,0.08);
|
||||||
|
border: 1px solid rgba(0,245,212,0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover {
|
||||||
|
background: rgba(0,245,212,0.15);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: var(--glow-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(5,8,16,0.8);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skills Grid */
|
||||||
|
.skills-grid {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-grid::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-grid::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-mid);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skill Card */
|
||||||
|
.skill-card {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card.inactive {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-icon {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-badge {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
border: 1px solid rgba(0,245,212,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-description {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--accent-purple-dim);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
border: 1px solid rgba(123,44,191,0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-badge.private {
|
||||||
|
background: rgba(255,71,87,0.1);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border: 1px solid rgba(255,71,87,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-badge.team {
|
||||||
|
background: rgba(249,168,37,0.1);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
border: 1px solid rgba(249,168,37,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-badge.market {
|
||||||
|
background: rgba(0,230,118,0.1);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border: 1px solid rgba(0,230,118,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.active {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.edit:hover {
|
||||||
|
border-color: var(--accent-amber);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.delete:hover {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: 520px;
|
||||||
|
max-height: 85vh;
|
||||||
|
background: rgba(10,15,26,.98);
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.6), 0 0 0 1px rgba(0,245,212,.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-mid);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea,
|
||||||
|
.form-select {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0,245,212,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-textarea {
|
||||||
|
font-size: 11px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tools Grid */
|
||||||
|
.tools-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-checkbox input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-checkbox .checkbox-custom {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-checkbox input:checked + .checkbox-custom {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-checkbox input:checked + .checkbox-custom::after {
|
||||||
|
content: '\2713';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: var(--bg-void);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: rgba(0,245,212,.1);
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: rgba(0,245,212,.2);
|
||||||
|
box-shadow: var(--glow-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-loader {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
border-top-color: var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation helpers */
|
||||||
|
@keyframes animateIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animateOut {
|
||||||
|
from { opacity: 1; transform: scale(1); }
|
||||||
|
to { opacity: 0; transform: scale(0.95); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user