feat(skills): distinguish agent templates from business skills in UI
Deploy to Production / deploy (push) Waiting to run
Details
Deploy to Production / deploy (push) Waiting to run
Details
The skills tab mixed generic execution-engine templates (react/direct/ rewoo/...) with business-domain skills (monitor/geo_optimizer/...) with no visual or data distinction. Adds a derived `category` field to the SkillInfo/SkillDetail API models and groups the frontend display. Backend: - SkillInfo/SkillDetail: add category (Literal), agent_type, execution_mode, task_mode fields - _skill_to_info: derive category from explicit _ENGINE_TEMPLATE_NAMES set (not name suffix — trend_agent/deai_agent are business skills despite the _agent suffix) - Simplify repetitive hasattr pattern with getattr Frontend: - ISkillInfo/ISkillDetail: add category + mode fields - skills store: agentTemplates/businessSkills computed getters (businessSkills is defensive: anything not explicitly engine template) - SkillsView: group into 执行引擎 / 业务技能 sections with counts - SkillCard: type badge (引擎/技能), category-based icon, mode display, dark-mode-aware accent color Tests: - test_category_derived_from_name_suffix: verifies field exposure - test_category_no_orphans: invariant — every skill has a valid category - test_trend_agent_classified_as_business_skill: regression guard for the _agent suffix misclassification bug Code review (ce-code-review): 2 P1 + 5 P2 findings applied.
This commit is contained in:
parent
e600722378
commit
a672dddc9a
|
|
@ -4,6 +4,8 @@ import { BaseApiClient } from './base'
|
||||||
|
|
||||||
const API_BASE = '/api/v1/skill-management'
|
const API_BASE = '/api/v1/skill-management'
|
||||||
|
|
||||||
|
export type SkillCategory = 'agent_template' | 'business_skill'
|
||||||
|
|
||||||
export interface ISkillInfo {
|
export interface ISkillInfo {
|
||||||
name: string
|
name: string
|
||||||
version: string
|
version: string
|
||||||
|
|
@ -11,6 +13,11 @@ export interface ISkillInfo {
|
||||||
capabilities: string[]
|
capabilities: string[]
|
||||||
dependencies: string[]
|
dependencies: string[]
|
||||||
status: string
|
status: string
|
||||||
|
/** "agent_template" = 通用执行引擎 (react/direct/rewoo/...); "business_skill" = 业务领域技能 */
|
||||||
|
category: SkillCategory
|
||||||
|
agent_type: string
|
||||||
|
execution_mode: string
|
||||||
|
task_mode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISkillDetail {
|
export interface ISkillDetail {
|
||||||
|
|
@ -21,6 +28,10 @@ export interface ISkillDetail {
|
||||||
dependencies: string[]
|
dependencies: string[]
|
||||||
config: Record<string, unknown>
|
config: Record<string, unknown>
|
||||||
health_status: string
|
health_status: string
|
||||||
|
category: SkillCategory
|
||||||
|
agent_type: string
|
||||||
|
execution_mode: string
|
||||||
|
task_mode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICapabilityInfo {
|
export interface ICapabilityInfo {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,32 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="skill-card" @click="$emit('click')">
|
<div class="skill-card" :class="`skill-card--${skill.category}`" @click="$emit('click')">
|
||||||
<a-card hoverable size="small">
|
<a-card hoverable size="small">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="skill-card__title">
|
<div class="skill-card__title">
|
||||||
<AppstoreOutlined class="skill-card__icon" />
|
<component :is="titleIcon" class="skill-card__icon" />
|
||||||
<span>{{ skill.name }}</span>
|
<span>{{ skill.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-badge
|
<div class="skill-card__badges">
|
||||||
:status="skill.status === 'active' ? 'success' : 'default'"
|
<a-tag
|
||||||
:text="skill.status === 'active' ? '正常' : skill.status"
|
:color="skill.category === 'agent_template' ? 'purple' : 'blue'"
|
||||||
/>
|
size="small"
|
||||||
|
class="skill-card__type-tag"
|
||||||
|
>
|
||||||
|
{{ skill.category === 'agent_template' ? '引擎' : '技能' }}
|
||||||
|
</a-tag>
|
||||||
|
<a-badge
|
||||||
|
:status="skill.status === 'active' ? 'success' : 'default'"
|
||||||
|
:text="skill.status === 'active' ? '正常' : skill.status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p class="skill-card__desc">{{ skill.description || '暂无描述' }}</p>
|
<p class="skill-card__desc">{{ skill.description || '暂无描述' }}</p>
|
||||||
|
<div v-if="modeText" class="skill-card__mode">
|
||||||
|
<span class="skill-card__mode-label">{{ modeLabel }}:</span>
|
||||||
|
<code class="skill-card__mode-value">{{ modeText }}</code>
|
||||||
|
</div>
|
||||||
<div class="skill-card__tags">
|
<div class="skill-card__tags">
|
||||||
<a-tag v-for="cap in skill.capabilities" :key="cap" size="small" color="blue">
|
<a-tag v-for="cap in skill.capabilities" :key="cap" size="small" color="blue">
|
||||||
{{ cap }}
|
{{ cap }}
|
||||||
|
|
@ -36,16 +49,34 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AppstoreOutlined } from '@ant-design/icons-vue'
|
import { computed } from 'vue'
|
||||||
|
import { ThunderboltOutlined, AppstoreOutlined } from '@ant-design/icons-vue'
|
||||||
import type { ISkillInfo } from '@/api/skills'
|
import type { ISkillInfo } from '@/api/skills'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
skill: ISkillInfo
|
skill: ISkillInfo
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
click: []
|
click: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Differentiate icon by category: engine = thunderbolt, business = appstore
|
||||||
|
const titleIcon = computed(() =>
|
||||||
|
props.skill.category === 'agent_template' ? ThunderboltOutlined : AppstoreOutlined
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show the most relevant mode field.
|
||||||
|
// Agent templates: execution_mode (react/direct/rewoo/...).
|
||||||
|
// Business skills: agent_type (the domain identifier).
|
||||||
|
const modeText = computed(() =>
|
||||||
|
props.skill.category === 'agent_template'
|
||||||
|
? props.skill.execution_mode
|
||||||
|
: props.skill.agent_type
|
||||||
|
)
|
||||||
|
const modeLabel = computed(() =>
|
||||||
|
props.skill.category === 'agent_template' ? '执行模式' : '领域类型'
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -68,6 +99,40 @@ defineEmits<{
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-card--agent_template .skill-card__icon {
|
||||||
|
color: var(--accent-board); /* purple — matches the 引擎 tag, dark-mode aware */
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card__badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card__type-tag {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card__mode {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
font-size: var(--font-xs, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card__mode-label {
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card__mode-value {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary, rgba(0, 0, 0, 0.04));
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.skill-card__desc {
|
.skill-card__desc {
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@ export const useSkillsStore = defineStore('skills', () => {
|
||||||
|
|
||||||
// --- Getters ---
|
// --- Getters ---
|
||||||
const skillNames = computed(() => skills.value.map((s) => s.name))
|
const skillNames = computed(() => skills.value.map((s) => s.name))
|
||||||
|
/** 通用执行引擎模板 (react/direct/rewoo/reflexion/...) */
|
||||||
|
const agentTemplates = computed(() =>
|
||||||
|
skills.value.filter((s) => s.category === 'agent_template')
|
||||||
|
)
|
||||||
|
/** 业务领域技能 (monitor/geo_optimizer/code_reviewer/...) — defensive: anything not explicitly an engine template */
|
||||||
|
const businessSkills = computed(() =>
|
||||||
|
skills.value.filter((s) => s.category !== 'agent_template')
|
||||||
|
)
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
async function fetchSkills(capability?: string, page?: number): Promise<void> {
|
async function fetchSkills(capability?: string, page?: number): Promise<void> {
|
||||||
|
|
@ -106,6 +114,8 @@ export const useSkillsStore = defineStore('skills', () => {
|
||||||
selectedCapability,
|
selectedCapability,
|
||||||
// Getters
|
// Getters
|
||||||
skillNames,
|
skillNames,
|
||||||
|
agentTemplates,
|
||||||
|
businessSkills,
|
||||||
// Actions
|
// Actions
|
||||||
fetchSkills,
|
fetchSkills,
|
||||||
fetchCapabilities,
|
fetchCapabilities,
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,46 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-spin :spinning="skillsStore.isLoading">
|
<a-spin :spinning="skillsStore.isLoading">
|
||||||
<div class="skills-view__grid">
|
<!-- 通用执行引擎模板 -->
|
||||||
<SkillCard
|
<section v-if="skillsStore.agentTemplates.length > 0" class="skills-view__group">
|
||||||
v-for="skill in skillsStore.skills"
|
<div class="skills-view__group-header">
|
||||||
:key="skill.name"
|
<ThunderboltOutlined class="skills-view__group-icon" />
|
||||||
:skill="skill"
|
<span class="skills-view__group-title">执行引擎</span>
|
||||||
@click="handleSkillClick(skill.name)"
|
<a-tag color="purple">{{ skillsStore.agentTemplates.length }}</a-tag>
|
||||||
/>
|
<span class="skills-view__group-hint">通用推理模式,决定 Agent 如何思考和调用工具</span>
|
||||||
</div>
|
</div>
|
||||||
<a-empty v-if="!skillsStore.isLoading && skillsStore.skills.length === 0" description="暂无已注册技能" />
|
<div class="skills-view__grid">
|
||||||
|
<SkillCard
|
||||||
|
v-for="skill in skillsStore.agentTemplates"
|
||||||
|
:key="skill.name"
|
||||||
|
:skill="skill"
|
||||||
|
@click="handleSkillClick(skill.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 业务领域技能 -->
|
||||||
|
<section v-if="skillsStore.businessSkills.length > 0" class="skills-view__group">
|
||||||
|
<div class="skills-view__group-header">
|
||||||
|
<AppstoreOutlined class="skills-view__group-icon" />
|
||||||
|
<span class="skills-view__group-title">业务技能</span>
|
||||||
|
<a-tag color="blue">{{ skillsStore.businessSkills.length }}</a-tag>
|
||||||
|
<span class="skills-view__group-hint">面向具体业务场景的领域能力</span>
|
||||||
|
</div>
|
||||||
|
<div class="skills-view__grid">
|
||||||
|
<SkillCard
|
||||||
|
v-for="skill in skillsStore.businessSkills"
|
||||||
|
:key="skill.name"
|
||||||
|
:skill="skill"
|
||||||
|
@click="handleSkillClick(skill.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a-empty
|
||||||
|
v-if="!skillsStore.isLoading && skillsStore.skills.length === 0"
|
||||||
|
description="暂无已注册技能"
|
||||||
|
/>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
|
|
||||||
<SkillDetail
|
<SkillDetail
|
||||||
|
|
@ -159,4 +190,34 @@ async function handleInstall(): Promise<void> {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skills-view__group {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-view__group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--border-color-split, rgba(0, 0, 0, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-view__group-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-view__group-title {
|
||||||
|
font-size: var(--font-md, 14px);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-view__group-hint {
|
||||||
|
font-size: var(--font-xs, 12px);
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
margin-left: var(--space-1);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -12,6 +12,20 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["skill-management"])
|
router = APIRouter(tags=["skill-management"])
|
||||||
|
|
||||||
|
# Explicit set of generic execution-engine templates (react/direct/rewoo/...).
|
||||||
|
# Everything else is a business-domain skill. Explicit (not name-suffix based)
|
||||||
|
# because trend_agent/deai_agent are business skills despite the _agent suffix.
|
||||||
|
# ponytail: ceiling — new engine templates must be added here. Upgrade path:
|
||||||
|
# add an explicit `category` field to SkillConfig (方案 C).
|
||||||
|
_ENGINE_TEMPLATE_NAMES = frozenset({
|
||||||
|
"react_agent",
|
||||||
|
"direct_agent",
|
||||||
|
"rewoo_agent",
|
||||||
|
"reflexion_agent",
|
||||||
|
"plan_exec_agent",
|
||||||
|
"goal_driven_agent",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Request / Response models
|
# Request / Response models
|
||||||
|
|
@ -25,6 +39,10 @@ class SkillInfo(BaseModel):
|
||||||
capabilities: list[str]
|
capabilities: list[str]
|
||||||
dependencies: list[str]
|
dependencies: list[str]
|
||||||
status: str
|
status: str
|
||||||
|
category: Literal["agent_template", "business_skill"]
|
||||||
|
agent_type: str = ""
|
||||||
|
execution_mode: str = ""
|
||||||
|
task_mode: str = ""
|
||||||
|
|
||||||
|
|
||||||
class SkillDetail(BaseModel):
|
class SkillDetail(BaseModel):
|
||||||
|
|
@ -35,6 +53,10 @@ class SkillDetail(BaseModel):
|
||||||
dependencies: list[str]
|
dependencies: list[str]
|
||||||
config: dict[str, Any]
|
config: dict[str, Any]
|
||||||
health_status: str
|
health_status: str
|
||||||
|
category: Literal["agent_template", "business_skill"]
|
||||||
|
agent_type: str = ""
|
||||||
|
execution_mode: str = ""
|
||||||
|
task_mode: str = ""
|
||||||
|
|
||||||
|
|
||||||
class CapabilityInfo(BaseModel):
|
class CapabilityInfo(BaseModel):
|
||||||
|
|
@ -50,29 +72,30 @@ class CapabilityInfo(BaseModel):
|
||||||
|
|
||||||
def _skill_to_info(skill: Any) -> dict[str, Any]:
|
def _skill_to_info(skill: Any) -> dict[str, Any]:
|
||||||
"""Convert a Skill object to a dict suitable for API responses."""
|
"""Convert a Skill object to a dict suitable for API responses."""
|
||||||
capabilities = []
|
config = getattr(skill, "config", None)
|
||||||
if hasattr(skill, "config") and hasattr(skill.config, "capabilities"):
|
|
||||||
caps = skill.config.capabilities
|
|
||||||
if isinstance(caps, list):
|
|
||||||
capabilities = [c.tag if hasattr(c, "tag") else str(c) for c in caps]
|
|
||||||
elif isinstance(caps, dict):
|
|
||||||
capabilities = list(caps.keys())
|
|
||||||
|
|
||||||
dependencies = []
|
capabilities: list[str] = []
|
||||||
if hasattr(skill, "config") and hasattr(skill.config, "dependencies"):
|
caps = getattr(config, "capabilities", None)
|
||||||
deps = skill.config.dependencies
|
if isinstance(caps, list):
|
||||||
if isinstance(deps, list):
|
capabilities = [c.tag if hasattr(c, "tag") else str(c) for c in caps]
|
||||||
dependencies = deps
|
elif isinstance(caps, dict):
|
||||||
elif isinstance(deps, dict):
|
capabilities = list(caps.keys())
|
||||||
dependencies = list(deps.keys())
|
|
||||||
|
|
||||||
version = ""
|
deps = getattr(config, "dependencies", None)
|
||||||
if hasattr(skill, "config") and hasattr(skill.config, "version"):
|
if isinstance(deps, list):
|
||||||
version = skill.config.version or ""
|
dependencies: list[str] = deps
|
||||||
|
elif isinstance(deps, dict):
|
||||||
|
dependencies = list(deps.keys())
|
||||||
|
else:
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
description = ""
|
version = getattr(config, "version", "") or ""
|
||||||
if hasattr(skill, "config") and hasattr(skill.config, "description"):
|
description = getattr(config, "description", "") or ""
|
||||||
description = skill.config.description or ""
|
agent_type = getattr(config, "agent_type", "") or ""
|
||||||
|
execution_mode = getattr(config, "execution_mode", "") or ""
|
||||||
|
task_mode = getattr(config, "task_mode", "") or ""
|
||||||
|
|
||||||
|
category = "agent_template" if skill.name in _ENGINE_TEMPLATE_NAMES else "business_skill"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": skill.name,
|
"name": skill.name,
|
||||||
|
|
@ -81,6 +104,10 @@ def _skill_to_info(skill: Any) -> dict[str, Any]:
|
||||||
"capabilities": capabilities,
|
"capabilities": capabilities,
|
||||||
"dependencies": dependencies,
|
"dependencies": dependencies,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
|
"category": category,
|
||||||
|
"agent_type": agent_type,
|
||||||
|
"execution_mode": execution_mode,
|
||||||
|
"task_mode": task_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,49 @@ class TestListSkills:
|
||||||
assert "dependencies" in skill
|
assert "dependencies" in skill
|
||||||
assert "status" in skill
|
assert "status" in skill
|
||||||
|
|
||||||
|
def test_category_derived_from_name_suffix(self, client, skill_registry):
|
||||||
|
"""category field distinguishes agent templates from business skills.
|
||||||
|
|
||||||
|
*_agent -> "agent_template"
|
||||||
|
others -> "business_skill"
|
||||||
|
Also verifies agent_type/execution_mode/task_mode are exposed.
|
||||||
|
"""
|
||||||
|
_register_skill(skill_registry, "react_agent", execution_mode="react")
|
||||||
|
_register_skill(skill_registry, "geo_optimizer")
|
||||||
|
|
||||||
|
response = client.get("/api/v1/skill-management/skills")
|
||||||
|
data = response.json()
|
||||||
|
by_name = {s["name"]: s for s in data["skills"]}
|
||||||
|
|
||||||
|
agent = by_name["react_agent"]
|
||||||
|
assert agent["category"] == "agent_template"
|
||||||
|
assert agent["execution_mode"] == "react"
|
||||||
|
assert agent["agent_type"] == "test_type"
|
||||||
|
assert agent["task_mode"] == "llm_generate"
|
||||||
|
|
||||||
|
biz = by_name["geo_optimizer"]
|
||||||
|
assert biz["category"] == "business_skill"
|
||||||
|
|
||||||
|
def test_category_no_orphans(self, client, skill_registry):
|
||||||
|
"""Every skill must fall into exactly one category — no orphans."""
|
||||||
|
_register_skill(skill_registry, "react_agent")
|
||||||
|
_register_skill(skill_registry, "geo_optimizer")
|
||||||
|
_register_skill(skill_registry, "trend_agent")
|
||||||
|
|
||||||
|
response = client.get("/api/v1/skill-management/skills")
|
||||||
|
data = response.json()
|
||||||
|
valid = {"agent_template", "business_skill"}
|
||||||
|
assert all(s["category"] in valid for s in data["skills"])
|
||||||
|
|
||||||
|
def test_trend_agent_classified_as_business_skill(self, client, skill_registry):
|
||||||
|
"""trend_agent has _agent suffix but is a business-domain skill, not an engine."""
|
||||||
|
_register_skill(skill_registry, "trend_agent")
|
||||||
|
|
||||||
|
response = client.get("/api/v1/skill-management/skills")
|
||||||
|
data = response.json()
|
||||||
|
skill = next(s for s in data["skills"] if s["name"] == "trend_agent")
|
||||||
|
assert skill["category"] == "business_skill"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /skill-management/skills/{skill_name}
|
# GET /skill-management/skills/{skill_name}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue