Compare commits

..

11 Commits

Author SHA1 Message Date
99c30d9534 feat: add SkillView page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:30:31 +08:00
9824bc2d6c feat: add SkillRegistry for agent integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:29:57 +08:00
fad41ce94a fix(settings): use local state in LLMTableRow to avoid props mutation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:29:49 +08:00
6966ced359 feat: add Skill route and navigation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:29:22 +08:00
0f63ac82f4 feat: add skill API client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:29:15 +08:00
c552f71e28 feat: add Skill API endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:28:20 +08:00
d3749817b0 feat(settings): create LLMTableRow component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:27:50 +08:00
cdde7e3bc9 feat: add SkillService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:27:47 +08:00
672adf9287 feat: add Skill Pydantic schemas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:27:36 +08:00
0e6828722c feat: add Skill model
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:26:25 +08:00
79f25a3a74 docs: update LLM config implementation plan
Fix delete constraint for embedding/rerank, add max 1 constraint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:25:32 +08:00
15 changed files with 2504 additions and 10 deletions

View File

@@ -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 []

View 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 = {}

View File

@@ -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)

View 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])

View File

@@ -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

View 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()

View 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}

View 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())

View File

@@ -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` 卡片列表 UItemplate 中的 `.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
View 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}`),
}

View File

@@ -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 },
] ]

View 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>

View File

@@ -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',

View File

@@ -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;

View 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>