feat(settings): 拆分设置页为懒加载标签页并支持子代理创建/删除

- ConfigPage 拆分为 16 个懒加载 tab + 2 个 modal,首屏体积大幅下降
- 抽取 CapabilityTabs 共享组件,统一专家/子代理能力配置 UI
- 后端新增 POST /api/subagents/create、DELETE /api/subagents/delete
- SubagentRuntime 新增 create_subagent/delete_subagent,含路径校验与 builtin 保护
- SubagentModal 支持 create/edit 双模式、body 编辑、自身排除防自递归
- SubagentsTab 增加搜索、provider/model 标签、删除二次确认
- 修复 reload() 使用进程 cwd 而非 self.cwd 的隔离缺陷
This commit is contained in:
oudecheng 2026-08-04 21:06:17 +08:00
parent 2e4b1931a6
commit c2cc072b2e
30 changed files with 3338 additions and 2549 deletions

View File

@ -578,6 +578,7 @@ pub async fn subagents_update(
capability: updated.capability.clone(), capability: updated.capability.clone(),
provider: updated.provider.clone(), provider: updated.provider.clone(),
model: updated.model.clone(), model: updated.model.clone(),
body: updated.body.clone(),
}); });
Ok(Json(SubagentUpdateResponse { Ok(Json(SubagentUpdateResponse {
@ -587,6 +588,124 @@ pub async fn subagents_update(
})) }))
} }
#[derive(Deserialize)]
pub struct SubagentCreateRequest {
pub name: String,
pub description: String,
#[serde(default)]
pub body: String,
pub scope: String,
#[serde(default)]
pub capability: CapabilityPolicy,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Deserialize)]
pub struct SubagentDeleteRequest {
pub name: String,
}
#[derive(Serialize)]
pub struct SubagentDeleteResponse {
pub success: bool,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// POST /api/subagents/create — Create a new subagent (writes SUBAGENT.md)
pub async fn subagents_create(
State(state): State<Arc<GatewayState>>,
Json(req): Json<SubagentCreateRequest>,
) -> Result<Json<SubagentUpdateResponse>, (StatusCode, String)> {
let scope = SubagentScope::parse(&req.scope).ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
format!("invalid scope: {}", req.scope),
)
})?;
let created = state
.subagent_runtime
.create_subagent(
scope,
&req.name,
&req.description,
&req.body,
&req.capability,
&req.provider,
&req.model,
true,
)
.map_err(|err| {
let status = if err.contains("already exists") {
StatusCode::CONFLICT
} else {
StatusCode::BAD_REQUEST
};
(status, err)
})?;
// 返回创建后的状态(含 disabled_in_scopes
let status = state
.subagent_runtime
.list_with_status()
.into_iter()
.find(|s| s.name == created.name)
.unwrap_or_else(|| SubagentWithStatus {
name: created.name.clone(),
description: created.description.clone(),
source: created.source.as_str().to_string(),
disabled_in_scopes: vec![],
capability: created.capability.clone(),
provider: created.provider.clone(),
model: created.model.clone(),
body: created.body.clone(),
});
Ok(Json(SubagentUpdateResponse {
success: true,
subagent: Some(status),
error: None,
}))
}
/// DELETE /api/subagents/delete?name= — Delete a subagent (removes SUBAGENT.md)
pub async fn subagents_delete(
State(state): State<Arc<GatewayState>>,
Query(req): Query<SubagentDeleteRequest>,
) -> Result<Json<SubagentDeleteResponse>, (StatusCode, Json<SubagentDeleteResponse>)> {
let path = state
.subagent_runtime
.delete_subagent(&req.name, true)
.map_err(|err| {
let status = if err.contains("not found") {
StatusCode::NOT_FOUND
} else if err.contains("builtin") {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
status,
Json(SubagentDeleteResponse {
success: false,
path: String::new(),
error: Some(err),
}),
)
})?;
Ok(Json(SubagentDeleteResponse {
success: true,
path: path.display().to_string(),
error: None,
}))
}
// ===================== Experts ===================== // ===================== Experts =====================
#[derive(Deserialize)] #[derive(Deserialize)]

View File

@ -271,6 +271,14 @@ pub async fn run(
"/api/subagents/update", "/api/subagents/update",
routing::put(http::subagents_update), routing::put(http::subagents_update),
) )
.route(
"/api/subagents/create",
routing::post(http::subagents_create),
)
.route(
"/api/subagents/delete",
routing::delete(http::subagents_delete),
)
.route("/api/experts", routing::get(http::experts_list)) .route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle)) .route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create)) .route("/api/experts/create", routing::post(http::experts_create))
@ -315,6 +323,14 @@ pub async fn run(
"/api/subagents/update", "/api/subagents/update",
routing::put(http::subagents_update), routing::put(http::subagents_update),
) )
.route(
"/api/subagents/create",
routing::post(http::subagents_create),
)
.route(
"/api/subagents/delete",
routing::delete(http::subagents_delete),
)
.route("/api/experts", routing::get(http::experts_list)) .route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle)) .route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create)) .route("/api/experts/create", routing::post(http::experts_create))

View File

@ -1136,6 +1136,10 @@ pub struct SubagentWithStatus {
/// 可选的 model 名(引用 config.json 的 models 表。None 时继承主智能体。 /// 可选的 model 名(引用 config.json 的 models 表。None 时继承主智能体。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
/// SUBAGENT.md 的 markdown 正文追加到系统提示词末尾。builtin 子代理为 None。
/// 前端编辑模态框需要回显此字段,与专家系统的 body 对齐。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -1268,8 +1272,11 @@ impl SubagentRuntime {
} }
/// 重新发现子代理并替换内存 catalog写回 SUBAGENT.md 后调用)。 /// 重新发现子代理并替换内存 catalog写回 SUBAGENT.md 后调用)。
///
/// 使用 `self.cwd` 而非进程 cwd 进行发现,确保与构造时的 cwd 一致
/// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。
pub fn reload(&self) -> Result<(), String> { pub fn reload(&self) -> Result<(), String> {
let new_catalog = SubagentCatalog::discover(&self.config); let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd);
let mut guard = self let mut guard = self
.catalog .catalog
.write() .write()
@ -1301,6 +1308,7 @@ impl SubagentRuntime {
capability: def.capability.clone(), capability: def.capability.clone(),
provider: def.provider.clone(), provider: def.provider.clone(),
model: def.model.clone(), model: def.model.clone(),
body: def.body.clone(),
} }
}) })
.collect(); .collect();
@ -1585,6 +1593,150 @@ impl SubagentRuntime {
} }
Ok(new_def) Ok(new_def)
} }
/// 创建子代理(在指定 scope 下创建 SUBAGENT.md 文件)。
/// 对齐 `ExpertRuntime::create_expert`。
/// - `name` 不能为空,不能包含路径分隔符或 `..`。
/// - `prompt_template` 为空时使用默认模板。
/// - `max_execution_secs` 为 None 时不写入 frontmatter。
/// - 同名子代理(含 builtin `general`)已存在时返回错误。
pub fn create_subagent(
&self,
scope: SubagentScope,
name: &str,
description: &str,
body: &str,
capability: &CapabilityPolicy,
provider: &Option<String>,
model: &Option<String>,
reload: bool,
) -> Result<SubagentDef, String> {
validate_subagent_name(name)?;
{
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
if catalog.find(name).is_some() {
return Err(format!("subagent '{}' already exists", name));
}
}
let source = match scope {
SubagentScope::User => SubagentSource::User,
SubagentScope::Project => SubagentSource::Project,
};
let path = subagent_file_path(scope, name, &self.cwd)?;
if path.exists() {
return Err(format!(
"subagent '{}' already exists at {}",
name,
path.display()
));
}
// 新建子代理使用默认提示词模板(与 builtin general 一致),不暴露给 UI 编辑
let prompt_template = SubagentDef::builtin_general().prompt_template;
write_subagent_file(
&path,
name,
description,
&prompt_template,
body,
capability,
None,
provider,
model,
)?;
let def = parse_subagent_file(&path, source)?;
if reload {
let _ = self.reload();
}
Ok(def)
}
/// 删除子代理(删除 SUBAGENT.md 所在目录)。
/// 对齐 `ExpertRuntime::delete_expert`。
/// - builtin 子代理path 为 None禁止删除。
/// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。
pub fn delete_subagent(
&self,
name: &str,
reload: bool,
) -> Result<PathBuf, String> {
validate_subagent_name(name)?;
let path = {
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
let def = catalog
.find(name)
.ok_or_else(|| format!("subagent '{}' not found", name))?;
def.path
.clone()
.ok_or_else(|| format!("builtin subagent '{}' cannot be deleted", name))?
};
if !path.exists() {
return Err(format!("subagent file not found at {}", path.display()));
}
let dir = path
.parent()
.ok_or_else(|| "subagent file has no parent directory".to_string())?;
// 仅当目录内只有 SUBAGENT.md 时才递归删除目录;
// 否则只删除 SUBAGENT.md保留用户其他文件
let only_subagent_file = std::fs::read_dir(dir)
.map_err(|err| format!("failed to read subagent directory: {}", err))?
.filter_map(|e| e.ok())
.filter(|e| e.file_name() != "SUBAGENT.md")
.count()
== 0;
if only_subagent_file {
std::fs::remove_dir_all(dir)
.map_err(|err| format!("failed to delete subagent directory: {}", err))?;
} else {
std::fs::remove_file(&path)
.map_err(|err| format!("failed to delete subagent file: {}", err))?;
}
if reload {
let _ = self.reload();
}
Ok(dir.to_path_buf())
}
}
/// 校验子代理名称:非空、无路径分隔符、无 `..`。
/// 对齐 `validate_expert_name`。
fn validate_subagent_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("subagent name cannot be empty".to_string());
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("subagent name must not contain path separators or '..'".to_string());
}
Ok(())
}
/// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。
/// 对齐 `expert_file_path`。
fn subagent_file_path(
scope: SubagentScope,
name: &str,
cwd: &Path,
) -> Result<PathBuf, String> {
let root = match scope {
SubagentScope::User => dirs::home_dir()
.map(|p| p.join(".picobot").join("subagents"))
.ok_or_else(|| "cannot determine user home directory".to_string())?,
SubagentScope::Project => cwd.join(".picobot").join("subagents"),
};
Ok(root.join(name).join("SUBAGENT.md"))
} }
/// 为子代理系统提供索引提示词 /// 为子代理系统提供索引提示词
@ -2455,4 +2607,218 @@ mod tests {
let err = result.unwrap_err(); let err = result.unwrap_err();
assert!(err.contains("builtin") || err.contains("not found")); assert!(err.contains("builtin") || err.contains("not found"));
} }
// ===== create_subagent / delete_subagent 测试 =====
//
// create_subagent 写入到 project scope 的固定路径 {cwd}/.picobot/subagents/{name}/SUBAGENT.md
// 与 expert create 一致。测试中以 temp.path() 作为 cwdproject root 即 temp/.picobot/subagents/。
fn make_runtime_with_cwd(cwd: &Path) -> SubagentRuntime {
let config = SubagentsConfig {
enabled: true,
sources: vec!["project".to_string()],
};
let catalog = SubagentCatalog::discover_with_cwd(&config, cwd);
SubagentRuntime::new(config, catalog, cwd.to_path_buf())
}
#[test]
fn create_subagent_writes_file_and_appears_in_list() {
let temp = tempfile::tempdir().unwrap();
let runtime = make_runtime_with_cwd(temp.path());
let cap = CapabilityPolicy {
allowed_skills: None,
denied_skills: vec!["skill_x".to_string()],
allowed_tools: Some(vec!["read".to_string()]),
denied_tools: vec![],
allowed_subagents: None,
denied_subagents: vec![],
};
let created = runtime
.create_subagent(
SubagentScope::Project,
"demo-create",
"demo create agent",
"demo body content",
&cap,
&None,
&None,
true,
)
.unwrap();
assert_eq!(created.name, "demo-create");
assert_eq!(created.description, "demo create agent");
assert_eq!(created.body.as_deref(), Some("demo body content"));
assert_eq!(created.source, SubagentSource::Project);
// 文件确实创建在 project root 下
let file_path = temp
.path()
.join(".picobot")
.join("subagents")
.join("demo-create")
.join("SUBAGENT.md");
assert!(file_path.exists(), "SUBAGENT.md should be created");
// list_with_status 能看到新子代理
let items = runtime.list_with_status();
let item = items.iter().find(|i| i.name == "demo-create").unwrap();
assert_eq!(item.description, "demo create agent");
assert_eq!(item.body.as_deref(), Some("demo body content"));
assert_eq!(
item.capability.denied_skills,
vec!["skill_x".to_string()]
);
}
#[test]
fn create_subagent_rejects_duplicate() {
let temp = tempfile::tempdir().unwrap();
let runtime = make_runtime_with_cwd(temp.path());
runtime
.create_subagent(
SubagentScope::Project,
"dup",
"first",
"",
&CapabilityPolicy::default(),
&None,
&None,
true,
)
.unwrap();
// 同名再次创建应失败
let result = runtime.create_subagent(
SubagentScope::Project,
"dup",
"second",
"",
&CapabilityPolicy::default(),
&None,
&None,
true,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("already exists"));
}
#[test]
fn create_subagent_rejects_builtin_name() {
let temp = tempfile::tempdir().unwrap();
let runtime = make_runtime_with_cwd(temp.path());
// builtin general 已存在,应拒绝
let result = runtime.create_subagent(
SubagentScope::Project,
"general",
"hijack",
"",
&CapabilityPolicy::default(),
&None,
&None,
true,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("already exists"));
}
#[test]
fn delete_subagent_removes_file_and_directory() {
let temp = tempfile::tempdir().unwrap();
let runtime = make_runtime_with_cwd(temp.path());
runtime
.create_subagent(
SubagentScope::Project,
"doomed",
"to be deleted",
"",
&CapabilityPolicy::default(),
&None,
&None,
true,
)
.unwrap();
let dir = temp
.path()
.join(".picobot")
.join("subagents")
.join("doomed");
let file_path = dir.join("SUBAGENT.md");
assert!(file_path.exists());
let deleted_dir = runtime.delete_subagent("doomed", true).unwrap();
assert_eq!(deleted_dir, dir);
assert!(!dir.exists(), "directory should be removed");
}
#[test]
fn delete_subagent_rejects_builtin() {
let runtime = SubagentRuntime::from_config(SubagentsConfig::default());
let result = runtime.delete_subagent("general", false);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("builtin") || err.contains("not found"));
}
#[test]
fn delete_subagent_rejects_nonexistent() {
let runtime = SubagentRuntime::from_config(SubagentsConfig::default());
let result = runtime.delete_subagent("never-existed", false);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn delete_subagent_preserves_other_files_in_directory() {
let temp = tempfile::tempdir().unwrap();
let runtime = make_runtime_with_cwd(temp.path());
runtime
.create_subagent(
SubagentScope::Project,
"mixed",
"has extra files",
"",
&CapabilityPolicy::default(),
&None,
&None,
true,
)
.unwrap();
// 在子代理目录内放一个用户文件
let extra_file = temp
.path()
.join(".picobot")
.join("subagents")
.join("mixed")
.join("notes.txt");
std::fs::write(&extra_file, "user notes").unwrap();
// 删除子代理:应只删 SUBAGENT.md保留 notes.txt 和目录
runtime.delete_subagent("mixed", true).unwrap();
assert!(extra_file.exists(), "user file should be preserved");
assert!(
temp.path()
.join(".picobot")
.join("subagents")
.join("mixed")
.exists(),
"directory should be preserved when it has other files"
);
assert!(
!temp.path()
.join(".picobot")
.join("subagents")
.join("mixed")
.join("SUBAGENT.md")
.exists(),
"SUBAGENT.md should be removed"
);
}
} }

View File

@ -11,6 +11,8 @@ export const API = {
subagents: '/api/subagents', subagents: '/api/subagents',
subagentsToggle: '/api/subagents/toggle', subagentsToggle: '/api/subagents/toggle',
subagentsUpdate: '/api/subagents/update', subagentsUpdate: '/api/subagents/update',
subagentsCreate: '/api/subagents/create',
subagentsDelete: '/api/subagents/delete',
experts: '/api/experts', experts: '/api/experts',
expertsToggle: '/api/experts/toggle', expertsToggle: '/api/experts/toggle',
expertsCreate: '/api/experts/create', expertsCreate: '/api/experts/create',

View File

@ -17,6 +17,22 @@ export async function toggleSubagent(
}); });
} }
export async function createSubagent(payload: {
name: string;
description: string;
body: string;
scope: string;
capability?: CapabilityPolicy;
provider?: string;
model?: string;
}): Promise<Response> {
return fetch(API.subagentsCreate, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
export async function updateSubagent(payload: { export async function updateSubagent(payload: {
name: string; name: string;
description?: string; description?: string;
@ -31,3 +47,8 @@ export async function updateSubagent(payload: {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export async function deleteSubagent(name: string): Promise<Response> {
const params = new URLSearchParams({ name });
return fetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' });
}

View File

@ -0,0 +1,167 @@
// CapabilityTabs - 技能/工具/子代理能力配置的共享 Tab 组件
// 供 ExpertModal 和 SubagentModal 复用,将三组能力配置从平铺 SectionCard
// 改为内嵌 Tab 切换,节省垂直空间
import { useState } from 'react';
import { Wrench, Bot, Sparkles } from 'lucide-react';
import { Field, CheckboxList } from './ui';
import type { CheckboxListOption } from './ui';
/** 三组能力配置(白名单 + 黑名单)的集合,与 CapabilityPolicy 一一对应 */
export interface CapabilityState {
allowedSkills: string[];
deniedSkills: string[];
allowedTools: string[];
deniedTools: string[];
allowedSubagents: string[];
deniedSubagents: string[];
}
export interface CapabilityTabsProps {
value: CapabilityState;
onChange: (next: CapabilityState) => void;
/** 技能/工具/子代理的可选列表(来自后端发现) */
skillOptions: CheckboxListOption[];
toolOptions: CheckboxListOption[];
subagentOptions: CheckboxListOption[];
/** 各列表为空时的提示文案 */
skillEmptyHint?: string;
toolEmptyHint?: string;
subagentEmptyHint?: string;
/** 子代理副标题:限制可加载的孙代理 / 限制可加载的子代理 */
subagentsSubtitle?: string;
}
type TabKey = 'skills' | 'tools' | 'subagents';
interface TabDef {
key: TabKey;
label: string;
icon: typeof Wrench;
}
const TABS: TabDef[] = [
{ key: 'skills', label: '技能', icon: Sparkles },
{ key: 'tools', label: '工具', icon: Wrench },
{ key: 'subagents', label: '子代理', icon: Bot },
];
export function CapabilityTabs({
value,
onChange,
skillOptions,
toolOptions,
subagentOptions,
skillEmptyHint,
toolEmptyHint,
subagentEmptyHint,
subagentsSubtitle = '限制可加载的孙代理',
}: CapabilityTabsProps) {
const [active, setActive] = useState<TabKey>('skills');
// 子代理勾选列表通常排除自身(在调用方已过滤),此处不重复处理
const setField = <K extends keyof CapabilityState>(key: K, v: CapabilityState[K]) =>
onChange({ ...value, [key]: v });
return (
<div className="rounded-xl border border-[var(--border-color)] overflow-hidden">
{/* Tab 头 */}
<div className="flex border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]">
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = active === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => setActive(tab.key)}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors ${
isActive
? 'text-[var(--accent-cyan)] bg-[var(--bg-secondary)] border-b-2 border-[var(--accent-cyan)] -mb-px'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
>
<Icon className="h-3.5 w-3.5" />
{tab.label}
</button>
);
})}
</div>
{/* Tab 内容:白名单 / 黑名单两列 */}
<div className="p-4">
{active === 'skills' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见">
<CheckboxList
options={skillOptions}
selected={value.allowedSkills}
onChange={(v) => setField('allowedSkills', v)}
extraSelected={value.allowedSkills}
emptyHint={skillEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={skillOptions}
selected={value.deniedSkills}
onChange={(v) => setField('deniedSkills', v)}
extraSelected={value.deniedSkills}
emptyHint={skillEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
)}
{active === 'tools' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的工具(白名单)" hint="留空表示不限。覆盖内置 + MCP 工具">
<CheckboxList
options={toolOptions}
selected={value.allowedTools}
onChange={(v) => setField('allowedTools', v)}
extraSelected={value.allowedTools}
emptyHint={toolEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的工具" hint="在白名单之后应用">
<CheckboxList
options={toolOptions}
selected={value.deniedTools}
onChange={(v) => setField('deniedTools', v)}
extraSelected={value.deniedTools}
emptyHint={toolEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
)}
{active === 'subagents' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的子代理(白名单)" hint={`留空表示不限。${subagentsSubtitle}`}>
<CheckboxList
options={subagentOptions}
selected={value.allowedSubagents}
onChange={(v) => setField('allowedSubagents', v)}
extraSelected={value.allowedSubagents}
emptyHint={subagentEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的子代理(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={subagentOptions}
selected={value.deniedSubagents}
onChange={(v) => setField('deniedSubagents', v)}
extraSelected={value.deniedSubagents}
emptyHint={subagentEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; // 网关连接设置的 localStorage 工具函数
import { X, Wifi, RotateCcw } from 'lucide-react'; // 原 SettingsModal 组件已删除(功能由 ConfigPage 的 Connection Tab 承担),
// 仅保留 App.tsx 使用的 getGatewaySettings / buildWsUrl / GatewaySettings 工具函数
export interface GatewaySettings { export interface GatewaySettings {
host: string; host: string;
@ -23,155 +24,3 @@ export function getGatewaySettings(): GatewaySettings {
export function buildWsUrl(settings: GatewaySettings): string { export function buildWsUrl(settings: GatewaySettings): string {
return `ws://${settings.host}:${settings.port}/ws`; return `ws://${settings.host}:${settings.port}/ws`;
} }
interface SettingsModalProps {
onClose: () => void;
onSave: (settings: GatewaySettings) => void;
}
export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
const [host, setHost] = useState(DEFAULT_HOST);
const [port, setPort] = useState(String(DEFAULT_PORT));
const [error, setError] = useState('');
useEffect(() => {
const settings = getGatewaySettings();
setHost(settings.host);
setPort(String(settings.port));
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleSave = () => {
const trimmedHost = host.trim();
const portNum = parseInt(port, 10);
if (!trimmedHost) {
setError('主机地址不能为空');
return;
}
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
setError('端口号必须在 1-65535 之间');
return;
}
setError('');
localStorage.setItem('picobot-gateway-host', trimmedHost);
localStorage.setItem('picobot-gateway-port', String(portNum));
onSave({ host: trimmedHost, port: portNum });
};
const handleReset = () => {
setHost(DEFAULT_HOST);
setPort(String(DEFAULT_PORT));
setError('');
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
{/* Modal container */}
<div
className="relative w-[90vw] max-w-md rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl flex flex-col overflow-hidden animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
<Wifi className="h-5 w-5 text-[var(--accent-cyan)]" />
<span className="text-lg font-semibold text-[var(--text-primary)]"></span>
<button
onClick={onClose}
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
aria-label="关闭"
title="关闭 (Esc)"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 p-6 space-y-5">
{/* Host */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">
</label>
<input
type="text"
value={host}
onChange={(e) => {
setHost(e.target.value);
setError('');
}}
placeholder="127.0.0.1"
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
/>
</div>
{/* Port */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">
</label>
<input
type="number"
value={port}
onChange={(e) => {
setPort(e.target.value);
setError('');
}}
placeholder="19876"
min={1}
max={65535}
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
/>
</div>
{/* Error */}
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
{/* Current URL preview */}
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono">
ws://{host.trim() || '...'}:{port || '...'}/ws
</div>
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3">
<button
onClick={handleReset}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)] transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<div className="flex-1" />
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
>
</button>
<button
onClick={handleSave}
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all"
>
</button>
</div>
</div>
</div>
);
}

View File

@ -17,24 +17,54 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import type { TabId } from './types'; import type { TabId } from './types';
export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ export interface TabDef {
id: TabId;
label: string;
icon: typeof Settings;
}
export interface TabGroup {
label: string;
tabs: TabDef[];
}
// 按功能分组展示,便于快速定位
export const TAB_GROUPS: TabGroup[] = [
{
label: 'AI 核心',
tabs: [
{ id: 'providers', label: '服务商', icon: Cpu }, { id: 'providers', label: '服务商', icon: Cpu },
{ id: 'models', label: '模型', icon: Brain }, { id: 'models', label: '模型', icon: Brain },
{ id: 'agents', label: '代理', icon: Bot }, { id: 'agents', label: '代理', icon: Bot },
{ id: 'experts', label: '专家', icon: UserCheck },
{ id: 'subagents', label: '子代理', icon: Bot },
],
},
{
label: '集成',
tabs: [
{ id: 'mcp', label: 'MCP 服务器', icon: Plug }, { id: 'mcp', label: 'MCP 服务器', icon: Plug },
{ id: 'skills', label: '技能', icon: Wrench }, { id: 'skills', label: '技能', icon: Wrench },
{ id: 'subagents', label: '子代理', icon: Bot },
{ id: 'experts', label: '专家', icon: UserCheck },
{ id: 'channels', label: '渠道', icon: Radio },
{ id: 'tools', label: '工具', icon: Settings }, { id: 'tools', label: '工具', icon: Settings },
{ id: 'memory', label: '记忆维护', icon: Users }, { id: 'channels', label: '渠道', icon: Radio },
],
},
{
label: '系统',
tabs: [
{ id: 'gateway', label: '网关', icon: Server },
{ id: 'scheduler', label: '调度器', icon: Calendar }, { id: 'scheduler', label: '调度器', icon: Calendar },
{ id: 'memory', label: '记忆维护', icon: Users },
{ id: 'image', label: '图片上下文', icon: Image }, { id: 'image', label: '图片上下文', icon: Image },
{ id: 'time', label: '时间', icon: Clock }, { id: 'time', label: '时间', icon: Clock },
{ id: 'connection', label: '连接', icon: Wifi }, { id: 'connection', label: '连接', icon: Wifi },
{ id: 'gateway', label: '网关', icon: Server }, ],
},
]; ];
// 扁平化,便于按 id 查找
export const TABS: TabDef[] = TAB_GROUPS.flatMap((g) => g.tabs);
export const inputCls = export const inputCls =
'w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors'; 'w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors';
export const selectCls = inputCls; export const selectCls = inputCls;

View File

@ -0,0 +1,304 @@
// ExpertModal - 专家编辑/创建模态框
// 挂载时通过 useSharedModalData 加载 skills/tools/subagents/modelOptions
import { useState, useEffect } from 'react';
import { UserCheck, Save, Loader2 } from 'lucide-react';
import { Field, SectionCard, ModalHeader, ModalFooter } from '../ui';
import { inputCls, selectCls } from '../constants';
import { ErrorBanner } from '../shared';
import { useSharedModalData } from '../useSharedModalData';
import { CapabilityTabs, type CapabilityState } from '../CapabilityTabs';
import {
createExpert,
updateExpert,
} from '../../../api/experts';
import type { CapabilityPolicy, ExpertItem } from '../types';
export interface ExpertDraft {
mode: 'create' | 'edit';
name?: string;
scope: string;
nameField: string;
description: string;
body: string;
provider: string;
model: string;
allowedSkills: string[];
deniedSkills: string[];
allowedTools: string[];
deniedTools: string[];
allowedSubagents: string[];
deniedSubagents: string[];
}
interface Props {
draft: ExpertDraft;
onClose: () => void;
onSaved: () => void;
setToast: (msg: string) => void;
}
export function ExpertModal({ draft, onClose, onSaved, setToast }: Props) {
const [form, setForm] = useState<ExpertDraft>(draft);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const shared = useSharedModalData(true);
// ESC 仅关闭本模态框capture 阶段拦截,阻止冒泡到 ConfigPage 的全局 ESC
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [onClose]);
const isEdit = form.mode === 'edit';
const canSave = form.nameField.trim().length > 0 && form.description.trim().length > 0;
const setField = <K extends keyof ExpertDraft>(key: K, value: ExpertDraft[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleCapabilityChange = (next: CapabilityState) => {
setForm((prev) => ({
...prev,
allowedSkills: next.allowedSkills,
deniedSkills: next.deniedSkills,
allowedTools: next.allowedTools,
deniedTools: next.deniedTools,
allowedSubagents: next.allowedSubagents,
deniedSubagents: next.deniedSubagents,
}));
};
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setError('');
try {
// allowed_* 为空时必须传 undefined后端 None=不限),
// 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。
// denied_* 为 Vec<String>,空数组即"不禁",可直接传。
const capability: CapabilityPolicy = {
allowed_skills: form.allowedSkills.length > 0 ? form.allowedSkills : undefined,
denied_skills: form.deniedSkills,
allowed_tools: form.allowedTools.length > 0 ? form.allowedTools : undefined,
denied_tools: form.deniedTools,
allowed_subagents:
form.allowedSubagents.length > 0 ? form.allowedSubagents : undefined,
denied_subagents: form.deniedSubagents,
};
const payload = {
name: form.nameField,
description: form.description,
body: form.body,
capability,
provider: form.provider || undefined,
model: form.model || undefined,
};
const resp = isEdit
? await updateExpert({ ...payload, scope: 'project' })
: await createExpert({ ...payload, scope: 'project' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
setError(data.error || data.message || '保存失败');
setSaving(false);
return;
}
setToast(isEdit ? '专家已更新' : '专家已创建');
onSaved();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '网络错误');
} finally {
setSaving(false);
}
};
const skillOptions = (shared.skillList?.skills ?? []).map((s) => ({
key: s.name,
label: s.name,
description: s.description,
group: s.source,
}));
const toolOptions = (shared.toolList?.tools ?? []).map((t) => ({
key: t.name,
label: t.name,
description: t.description,
group: t.source,
}));
const subagentOptions = (shared.subagentList?.subagents ?? []).map((s) => ({
key: s.name,
label: s.name,
description: s.description,
group: s.source,
}));
const skillEmptyHint = shared.loading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录';
const toolEmptyHint = shared.loading ? '加载中...' : '未发现任何工具';
const subagentEmptyHint = shared.loading ? '加载中...' : '未发现任何子代理';
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl"
onClick={onClose}
>
<div
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<ModalHeader
icon={<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />}
title={isEdit ? '编辑专家' : '添加专家'}
onClose={onClose}
/>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
<SectionCard title="基本信息">
<Field
label="名称"
hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入"
>
<input
value={form.nameField}
onChange={(e) => setField('nameField', e.target.value)}
disabled={isEdit}
placeholder="如 translator"
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
autoFocus={!isEdit}
/>
</Field>
<Field
label="描述"
hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入"
>
<input
value={form.description}
onChange={(e) => setField('description', e.target.value)}
placeholder="如 翻译专家"
className={inputCls}
/>
</Field>
<Field
label="专家提示词正文"
hint="markdown 格式。非空时仅注入正文;为空时自动用“名称+描述”生成兜底提示词"
>
<textarea
value={form.body}
onChange={(e) => setField('body', e.target.value)}
placeholder="你是一名专业翻译..."
className={inputCls + ' min-h-[160px] resize-y font-mono text-xs'}
/>
</Field>
</SectionCard>
<SectionCard title="模型配置" subtitle="留空继承默认">
<Field label="Provider" hint="留空继承默认配置">
<select
value={form.provider}
onChange={(e) => setField('provider', e.target.value)}
className={selectCls}
>
<option value=""></option>
{(shared.modelOptions?.providers ?? []).map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</Field>
<Field label="Model" hint="留空继承默认配置">
<select
value={form.model}
onChange={(e) => setField('model', e.target.value)}
className={selectCls}
>
<option value=""></option>
{(shared.modelOptions?.models ?? []).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
</SectionCard>
<SectionCard title="能力配置" subtitle="白名单取交集,黑名单扣除">
<CapabilityTabs
value={{
allowedSkills: form.allowedSkills,
deniedSkills: form.deniedSkills,
allowedTools: form.allowedTools,
deniedTools: form.deniedTools,
allowedSubagents: form.allowedSubagents,
deniedSubagents: form.deniedSubagents,
}}
onChange={handleCapabilityChange}
skillOptions={skillOptions}
toolOptions={toolOptions}
subagentOptions={subagentOptions}
skillEmptyHint={skillEmptyHint}
toolEmptyHint={toolEmptyHint}
subagentEmptyHint={subagentEmptyHint}
subagentsSubtitle="仅勾选的子代理可被加载"
/>
</SectionCard>
<ErrorBanner>{error}</ErrorBanner>
</div>
<ModalFooter>
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={!canSave || saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? '保存中...' : '保存'}
</button>
</ModalFooter>
</div>
</div>
);
}
/** 从已有 ExpertItem 构造编辑用 draft */
export function expertToDraft(expert: ExpertItem): ExpertDraft {
return {
mode: 'edit',
name: expert.name,
scope: 'project',
nameField: expert.name,
description: expert.description,
body: expert.body ?? '',
provider: expert.provider ?? '',
model: expert.model ?? '',
allowedSkills: expert.capability?.allowed_skills ?? [],
deniedSkills: expert.capability?.denied_skills ?? [],
allowedTools: expert.capability?.allowed_tools ?? [],
deniedTools: expert.capability?.denied_tools ?? [],
allowedSubagents: expert.capability?.allowed_subagents ?? [],
deniedSubagents: expert.capability?.denied_subagents ?? [],
};
}
/** 构造创建用空 draft */
export function emptyExpertDraft(): ExpertDraft {
return {
mode: 'create',
scope: 'project',
nameField: '',
description: '',
body: '',
provider: '',
model: '',
allowedSkills: [],
deniedSkills: [],
allowedTools: [],
deniedTools: [],
allowedSubagents: [],
deniedSubagents: [],
};
}

View File

@ -0,0 +1,326 @@
// SubagentModal - 子代理编辑/创建模态框
// 挂载时通过 useSharedModalData 加载 skills/tools/subagents/modelOptions
// 与 ExpertModal 对齐:支持 body 编辑、create/edit 双模式、能力配置用 CapabilityTabs
import { useState, useEffect } from 'react';
import { Bot, Save, Loader2 } from 'lucide-react';
import { Field, SectionCard, ModalHeader, ModalFooter } from '../ui';
import { inputCls, selectCls } from '../constants';
import { ErrorBanner } from '../shared';
import { useSharedModalData } from '../useSharedModalData';
import { CapabilityTabs, type CapabilityState } from '../CapabilityTabs';
import { createSubagent, updateSubagent } from '../../../api/subagents';
import type { CapabilityPolicy, SubagentItem } from '../types';
export interface SubagentDraft {
mode: 'create' | 'edit';
name: string;
description: string;
body: string;
provider: string;
model: string;
allowedSkills: string[];
deniedSkills: string[];
allowedTools: string[];
deniedTools: string[];
allowedSubagents: string[];
deniedSubagents: string[];
}
interface Props {
draft: SubagentDraft;
onClose: () => void;
onSaved: () => void;
setToast: (msg: string) => void;
}
export function SubagentModal({ draft, onClose, onSaved, setToast }: Props) {
const [form, setForm] = useState<SubagentDraft>(draft);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const shared = useSharedModalData(true);
// ESC 仅关闭本模态框capture 阶段拦截,阻止冒泡到 ConfigPage 的全局 ESC
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [onClose]);
const isEdit = form.mode === 'edit';
const canSave = form.name.trim().length > 0 && form.description.trim().length > 0;
// 排除自身避免子代理勾选自己造成意外自递归max_nesting_depth 仍兜底)
const selfName = draft.name;
const setField = <K extends keyof SubagentDraft>(key: K, value: SubagentDraft[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleCapabilityChange = (next: CapabilityState) => {
setForm((prev) => ({
...prev,
allowedSkills: next.allowedSkills,
deniedSkills: next.deniedSkills,
allowedTools: next.allowedTools,
deniedTools: next.deniedTools,
allowedSubagents: next.allowedSubagents,
deniedSubagents: next.deniedSubagents,
}));
};
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setError('');
try {
// 与专家一致allowed_* 为空时传 undefinedNone=不限denied_* 空数组即"不禁"
// 必须包含全部 6 个字段,否则后端 #[serde(default)] 会让缺失字段变为 None/vec![]
// 经 next_capability 完全覆盖原 capability导致既有策略被清空数据丢失
const capability: CapabilityPolicy = {
allowed_skills: form.allowedSkills.length > 0 ? form.allowedSkills : undefined,
denied_skills: form.deniedSkills,
allowed_tools: form.allowedTools.length > 0 ? form.allowedTools : undefined,
denied_tools: form.deniedTools,
allowed_subagents:
form.allowedSubagents.length > 0 ? form.allowedSubagents : undefined,
denied_subagents: form.deniedSubagents,
};
if (isEdit) {
const resp = await updateSubagent({
name: form.name,
description: form.description,
body: form.body,
capability,
provider: form.provider || undefined,
model: form.model || undefined,
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
setError(data.error || data.message || '保存失败');
setSaving(false);
return;
}
setToast('子代理已更新');
} else {
const resp = await createSubagent({
name: form.name.trim(),
description: form.description,
body: form.body,
scope: 'project',
capability,
provider: form.provider || undefined,
model: form.model || undefined,
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
setError(data.error || data.message || '创建失败');
setSaving(false);
return;
}
setToast('子代理已创建');
}
onSaved();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '网络错误');
} finally {
setSaving(false);
}
};
const skillOptions = (shared.skillList?.skills ?? []).map((s) => ({
key: s.name,
label: s.name,
description: s.description,
group: s.source,
}));
const toolOptions = (shared.toolList?.tools ?? []).map((t) => ({
key: t.name,
label: t.name,
description: t.description,
group: t.source,
}));
const subagentOptionsAll = (shared.subagentList?.subagents ?? []).map((s) => ({
key: s.name,
label: s.name,
description: s.description,
group: s.source,
}));
// 子代理勾选列表排除自身(仅 edit 模式create 模式自身还不存在)
const subagentOptions = isEdit
? subagentOptionsAll.filter((o) => o.key !== selfName)
: subagentOptionsAll;
const skillEmptyHint = shared.loading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录';
const toolEmptyHint = shared.loading ? '加载中...' : '未发现任何工具';
const subagentEmptyHint = shared.loading ? '加载中...' : '未发现任何子代理';
const capabilityValue: CapabilityState = {
allowedSkills: form.allowedSkills,
deniedSkills: form.deniedSkills,
allowedTools: form.allowedTools,
deniedTools: form.deniedTools,
allowedSubagents: form.allowedSubagents,
deniedSubagents: form.deniedSubagents,
};
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl"
onClick={onClose}
>
<div
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<ModalHeader
icon={<Bot className="h-5 w-5 text-[var(--accent-cyan)]" />}
title={isEdit ? '编辑子代理' : '添加子代理'}
onClose={onClose}
/>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* 基础配置:名称 + 描述 + provider + model 合并到一个 SectionCard */}
<SectionCard title="基础配置">
<Field
label="名称"
hint={isEdit ? '创建后不可修改' : '仅字母、数字、下划线、连字符。创建后不可修改'}
>
<input
value={form.name}
onChange={(e) => setField('name', e.target.value)}
disabled={isEdit}
placeholder="如 researcher"
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
autoFocus={!isEdit}
/>
</Field>
<Field label="描述" hint="子代理的简短描述,用于主智能体选择">
<input
value={form.description}
onChange={(e) => setField('description', e.target.value)}
className={inputCls}
placeholder="如 研究专家"
/>
</Field>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Provider" hint="留空继承默认配置">
<select
value={form.provider}
onChange={(e) => setField('provider', e.target.value)}
className={selectCls}
>
<option value=""></option>
{(shared.modelOptions?.providers ?? []).map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</Field>
<Field label="Model" hint="留空继承默认配置">
<select
value={form.model}
onChange={(e) => setField('model', e.target.value)}
className={selectCls}
>
<option value=""></option>
{(shared.modelOptions?.models ?? []).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
</div>
</SectionCard>
{/* 提示词正文:独立 SectionCard与 ExpertModal 对齐 */}
<SectionCard
title="提示词正文"
subtitle="markdown 格式,追加到系统提示词末尾。留空则不追加"
>
<Field label="Body" hint="子代理的具体指令。支持 markdown会拼接到默认系统提示词之后">
<textarea
value={form.body}
onChange={(e) => setField('body', e.target.value)}
placeholder="你是一个专注的研究助手..."
className={inputCls + ' min-h-[160px] resize-y font-mono text-xs'}
/>
</Field>
</SectionCard>
{/* 能力配置:内嵌 Tab节省垂直空间 */}
<SectionCard title="能力配置" subtitle="白名单取交集,黑名单扣除">
<CapabilityTabs
value={capabilityValue}
onChange={handleCapabilityChange}
skillOptions={skillOptions}
toolOptions={toolOptions}
subagentOptions={subagentOptions}
skillEmptyHint={skillEmptyHint}
toolEmptyHint={toolEmptyHint}
subagentEmptyHint={subagentEmptyHint}
subagentsSubtitle="仅勾选的子代理可被加载为孙代理"
/>
</SectionCard>
<ErrorBanner>{error}</ErrorBanner>
</div>
<ModalFooter>
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={!canSave || saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? '保存中...' : '保存'}
</button>
</ModalFooter>
</div>
</div>
);
}
/** 从已有 SubagentItem 构造编辑用 draft */
export function subagentToDraft(subagent: SubagentItem): SubagentDraft {
return {
mode: 'edit',
name: subagent.name,
description: subagent.description,
body: subagent.body ?? '',
provider: subagent.provider ?? '',
model: subagent.model ?? '',
allowedSkills: subagent.capability?.allowed_skills ?? [],
deniedSkills: subagent.capability?.denied_skills ?? [],
allowedTools: subagent.capability?.allowed_tools ?? [],
deniedTools: subagent.capability?.denied_tools ?? [],
allowedSubagents: subagent.capability?.allowed_subagents ?? [],
deniedSubagents: subagent.capability?.denied_subagents ?? [],
};
}
/** 构造创建用空 draft */
export function emptySubagentDraft(): SubagentDraft {
return {
mode: 'create',
name: '',
description: '',
body: '',
provider: '',
model: '',
allowedSkills: [],
deniedSkills: [],
allowedTools: [],
deniedTools: [],
allowedSubagents: [],
deniedSubagents: [],
};
}

View File

@ -0,0 +1,37 @@
// Shared types and small components for Tab components
import type { ReactNode } from 'react';
import { Plus } from 'lucide-react';
import type { AppConfig } from './types';
/** 所有 Tab 组件共享的 props完整 config + 顶层 update 回调 */
export interface TabProps {
config: AppConfig;
update: <K extends keyof AppConfig>(key: K, value: AppConfig[K]) => void;
}
/** 用于显示 toast 消息的回调(如切换失败提示) */
export interface ToastProps {
setToast: (msg: string) => void;
}
/** 通用「添加 XXX」虚线按钮统一各 Tab 样式 */
export function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center"
>
<Plus className="h-4 w-4" /> {label}
</button>
);
}
/** 简单错误/提示气泡,统一红色错误样式 */
export function ErrorBanner({ children }: { children: ReactNode }) {
if (!children) return null;
return (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{children}
</div>
);
}

View File

@ -0,0 +1,97 @@
// AgentsTab - 代理配置Map 编辑器模式)
import { Field, MapEntryHeader } from '../ui';
import { inputCls, selectCls } from '../constants';
import { AddButton } from '../shared';
import { useMapEditor } from '../useMapEditor';
import type { TabProps } from '../shared';
import type { AgentConfig } from '../types';
export function AgentsTab({ config, update }: TabProps) {
const providerNames = Object.keys(config.providers);
const modelNames = Object.keys(config.models);
const editors = useMapEditor<AgentConfig>(config.agents, (v) => update('agents', v));
const addAgent = () => {
const name = prompt('Agent 名称:')?.trim();
if (name && !editors.has(name)) {
editors.add(name, {
provider: providerNames[0] || '',
model: modelNames[0] || '',
max_tool_iterations: 100,
tool_result_max_chars: 100000,
context_tool_result_trim_chars: 2000,
});
}
};
const delAgent = (name: string) => {
if (confirm(`删除 Agent "${name}"?`)) editors.remove(name);
};
return (
<div className="space-y-4">
{editors.entries.map(([name, a]) => (
<div
key={name}
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
>
<MapEntryHeader name={name} onDelete={() => delAgent(name)} />
<div className="p-4 space-y-3">
<Field label="Provider">
<select
value={a.provider}
onChange={(e) => editors.patch(name, { provider: e.target.value })}
className={selectCls}
>
{providerNames.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</Field>
<Field label="Model">
<select
value={a.model}
onChange={(e) => editors.patch(name, { model: e.target.value })}
className={selectCls}
>
{modelNames.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
<Field label="最大工具迭代次数">
<input
type="number"
value={a.max_tool_iterations}
onChange={(e) => editors.patch(name, { max_tool_iterations: +e.target.value })}
className={inputCls}
/>
</Field>
<Field label="工具结果最大字符数">
<input
type="number"
value={a.tool_result_max_chars}
onChange={(e) => editors.patch(name, { tool_result_max_chars: +e.target.value })}
className={inputCls}
/>
</Field>
<Field label="上下文工具结果裁剪字符数">
<input
type="number"
value={a.context_tool_result_trim_chars}
onChange={(e) =>
editors.patch(name, { context_tool_result_trim_chars: +e.target.value })
}
className={inputCls}
/>
</Field>
</div>
</div>
))}
<AddButton label="添加代理" onClick={addAgent} />
</div>
);
}

View File

@ -0,0 +1,157 @@
// ChannelsTab - 渠道配置Map 编辑器模式)
import { Field, Toggle, MapEntryHeader } from '../ui';
import { inputCls, selectCls } from '../constants';
import { AddButton } from '../shared';
import { useMapEditor } from '../useMapEditor';
import type { TabProps } from '../shared';
import type { ChannelConfig } from '../types';
function getChannelType(ch: ChannelConfig): string {
if (ch.type) return ch.type;
if ('app_id' in ch || 'app_secret' in ch) return 'feishu';
if ('cred_path' in ch) return 'wechat';
return 'feishu';
}
export function ChannelsTab({ config, update }: TabProps) {
const editors = useMapEditor<ChannelConfig>(config.channels, (v) => update('channels', v));
const addChannel = () => {
const name = prompt('渠道名称:')?.trim();
if (name && !editors.has(name)) {
editors.add(name, { type: 'feishu', enabled: false, app_id: '', app_secret: '' });
}
};
const delChannel = (name: string) => {
if (confirm(`删除渠道 "${name}"?`)) editors.remove(name);
};
return (
<div className="space-y-4">
{editors.entries.map(([name, ch]) => {
const chType = getChannelType(ch);
return (
<div
key={name}
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
>
<MapEntryHeader name={name} onDelete={() => delChannel(name)} />
<div className="p-4 space-y-3">
<Field label="渠道类型">
<select
value={chType}
onChange={(e) => editors.patch(name, { type: e.target.value })}
className={selectCls}
>
<option value="feishu"> (Feishu)</option>
<option value="wechat"> (WeChat)</option>
</select>
</Field>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"></span>
<Toggle
checked={!!ch.enabled}
onChange={(v) => editors.patch(name, { enabled: v })}
/>
</div>
{chType === 'feishu' && (
<>
<Field label="App ID">
<input
value={ch.app_id ?? ''}
onChange={(e) => editors.patch(name, { app_id: e.target.value })}
className={inputCls}
/>
</Field>
<Field label="App Secret">
<input
type="password"
value={ch.app_secret ?? ''}
onChange={(e) => editors.patch(name, { app_secret: e.target.value })}
className={inputCls}
/>
</Field>
<Field label="绑定 Agent" hint="留空使用 default">
<select
value={ch.agent ?? ''}
onChange={(e) => editors.patch(name, { agent: e.target.value })}
className={selectCls}
>
<option value="">default</option>
{Object.keys(config.agents).map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</Field>
<Field label="最大消息字符数">
<input
type="number"
value={ch.max_message_chars ?? 20000}
onChange={(e) =>
editors.patch(name, { max_message_chars: +e.target.value })
}
className={inputCls}
/>
</Field>
<Field label="回复上下文最大字符数">
<input
type="number"
value={ch.reply_context_max_chars ?? 20000}
onChange={(e) =>
editors.patch(name, { reply_context_max_chars: +e.target.value })
}
className={inputCls}
/>
</Field>
</>
)}
{chType === 'wechat' && (
<>
<Field label="凭证文件路径">
<input
value={ch.cred_path ?? ''}
onChange={(e) => editors.patch(name, { cred_path: e.target.value })}
className={inputCls}
placeholder="~/.picobot/wechat/credentials.json"
/>
</Field>
<Field label="Base URL">
<input
value={ch.base_url ?? 'https://ilinkai.weixin.qq.com'}
onChange={(e) => editors.patch(name, { base_url: e.target.value })}
className={inputCls}
/>
</Field>
<Field label="绑定 Agent" hint="留空使用 default">
<select
value={ch.agent ?? ''}
onChange={(e) => editors.patch(name, { agent: e.target.value })}
className={selectCls}
>
<option value="">default</option>
{Object.keys(config.agents).map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</Field>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"></span>
<Toggle
checked={!!ch.force_login}
onChange={(v) => editors.patch(name, { force_login: v })}
/>
</div>
</>
)}
</div>
</div>
);
})}
<AddButton label="添加渠道" onClick={addChannel} />
</div>
);
}

View File

@ -0,0 +1,89 @@
// ConnectionTab - WebSocket 连接设置localStorage 持久化,不走后端 config
import { useState } from 'react';
import { Wifi } from 'lucide-react';
import { Field, SectionCard } from '../ui';
import { inputCls } from '../constants';
import { ErrorBanner } from '../shared';
interface ConnectionTabProps {
onSaveConnection?: (host: string, port: number) => void;
setToast: (msg: string) => void;
}
export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps) {
const [connHost, setConnHost] = useState(() => {
try {
return localStorage.getItem('picobot-gateway-host') || '127.0.0.1';
} catch {
return '127.0.0.1';
}
});
const [connPort, setConnPort] = useState(() => {
try {
const p = parseInt(localStorage.getItem('picobot-gateway-port') || '19876', 10);
return isNaN(p) ? 19876 : p;
} catch {
return 19876;
}
});
const [connError, setConnError] = useState('');
const handleSaveConnection = () => {
const host = connHost.trim();
if (!host) {
setConnError('主机地址不能为空');
return;
}
if (connPort < 1 || connPort > 65535) {
setConnError('端口号必须在 1-65535 之间');
return;
}
setConnError('');
localStorage.setItem('picobot-gateway-host', host);
localStorage.setItem('picobot-gateway-port', String(connPort));
onSaveConnection?.(host, connPort);
setToast('连接设置已保存,正在重连...');
};
return (
<div className="space-y-5">
<SectionCard title="WebSocket 连接">
<Field label="主机地址">
<input
value={connHost}
onChange={(e) => {
setConnHost(e.target.value);
setConnError('');
}}
className={inputCls}
placeholder="127.0.0.1"
/>
</Field>
<Field label="端口号">
<input
type="number"
value={connPort}
onChange={(e) => {
setConnPort(+e.target.value);
setConnError('');
}}
min={1}
max={65535}
className={inputCls}
placeholder="19876"
/>
</Field>
<ErrorBanner>{connError}</ErrorBanner>
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono">
ws://{connHost.trim() || '...'}:{connPort || '...'}/ws
</div>
</SectionCard>
<button
onClick={handleSaveConnection}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 transition-all"
>
<Wifi className="h-4 w-4" />
</button>
</div>
);
}

View File

@ -0,0 +1,171 @@
// ExpertsTab - 专家系统配置(来源目录 + 已发现专家 + 编辑/创建模态框)
import { useState, useEffect, useCallback } from 'react';
import { Loader2, Pencil, Trash2 } from 'lucide-react';
import { Toggle, SectionCard, SourceEditor } from '../ui';
import { AddButton } from '../shared';
import { ExpertModal, expertToDraft, emptyExpertDraft, type ExpertDraft } from '../modals/ExpertModal';
import {
listExperts,
toggleExpert,
deleteExpert,
} from '../../../api/experts';
import type { TabProps, ToastProps } from '../shared';
import type { KnownSource, ExpertListResponse } from '../types';
type Props = TabProps & ToastProps;
const EXPERT_KNOWN_SOURCES: KnownSource[] = [
{ key: 'user', label: '用户专家', description: '~/.picobot/experts' },
{ key: 'project', label: '项目专家', description: '.picobot/experts' },
];
export function ExpertsTab({ config, update, setToast }: Props) {
const [expertList, setExpertList] = useState<ExpertListResponse | null>(null);
const [expertListLoading, setExpertListLoading] = useState(false);
const [editingExpert, setEditingExpert] = useState<ExpertDraft | null>(null);
const fetchExpertList = useCallback(async () => {
setExpertListLoading(true);
const data = await listExperts();
if (data) setExpertList(data);
setExpertListLoading(false);
}, []);
useEffect(() => {
fetchExpertList();
}, [fetchExpertList]);
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
if (!expertList) return;
const prevList = expertList;
setExpertList({
...expertList,
experts: expertList.experts.map((e) =>
e.name === name ? { ...e, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : e,
),
});
try {
const resp = await toggleExpert(name, 'project', !currentlyEnabled);
const data = await resp.json();
if (!resp.ok || !data.success) {
setExpertList(prevList);
setToast(data.error || '切换专家状态失败');
return;
}
setExpertList({
...prevList,
experts: prevList.experts.map((e) =>
e.name === name ? { ...e, disabled_in_scopes: data.disabled_in_scopes || [] } : e,
),
});
} catch {
setExpertList(prevList);
setToast('网络错误,切换专家状态失败');
}
};
const handleDelete = async (name: string) => {
if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return;
try {
const resp = await deleteExpert(name, 'project');
const data = await resp.json();
if (!resp.ok || !data.success) {
setToast(data.error || '删除专家失败');
return;
}
setToast('专家已删除');
fetchExpertList();
} catch {
setToast('网络错误,删除专家失败');
}
};
return (
<div className="space-y-5">
<SectionCard title="专家系统">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"></span>
<Toggle
checked={config.experts.enabled}
onChange={(v) => update('experts', { ...config.experts, enabled: v })}
/>
</div>
</SectionCard>
<SectionCard title="来源目录">
<SourceEditor
sources={config.experts.sources}
onChange={(v) => update('experts', { ...config.experts, sources: v })}
knownSources={EXPERT_KNOWN_SOURCES}
examplePaths={['D:\\my-experts', '/home/user/shared-experts']}
/>
</SectionCard>
{expertList && expertList.experts_system_enabled && (
<SectionCard title="已发现专家" subtitle="即时生效">
{expertListLoading && expertList.experts.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : expertList.experts.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{expertList.experts.map((expert) => {
const isEnabled = expert.disabled_in_scopes.length === 0;
return (
<div
key={expert.name}
className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">
{expert.name}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">
{expert.source}
</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
{expert.description}
</p>
</div>
<button
onClick={() => setEditingExpert(expertToDraft(expert))}
className="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="编辑"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(expert.name)}
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Toggle
checked={isEnabled}
onChange={() => handleToggle(expert.name, isEnabled)}
/>
</div>
);
})}
</div>
)}
</SectionCard>
)}
<AddButton label="添加专家" onClick={() => setEditingExpert(emptyExpertDraft())} />
{editingExpert && (
<ExpertModal
draft={editingExpert}
onClose={() => setEditingExpert(null)}
onSaved={() => {
setEditingExpert(null);
fetchExpertList();
}}
setToast={setToast}
/>
)}
</div>
);
}

View File

@ -0,0 +1,69 @@
// GatewayTab - 网关配置
import { Field, Toggle, SectionCard } from '../ui';
import { inputCls } from '../constants';
import type { TabProps } from '../shared';
export function GatewayTab({ config, update }: TabProps) {
return (
<div className="space-y-5">
<SectionCard title="连接">
<Field label="主机地址">
<input
value={config.gateway.host}
onChange={(e) => update('gateway', { ...config.gateway, host: e.target.value })}
className={inputCls}
/>
</Field>
<Field label="端口">
<input
type="number"
value={config.gateway.port}
onChange={(e) => update('gateway', { ...config.gateway, port: +e.target.value })}
className={inputCls}
/>
</Field>
</SectionCard>
<SectionCard title="行为">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"></span>
<Toggle
checked={config.gateway.show_tool_results}
onChange={(v) => update('gateway', { ...config.gateway, show_tool_results: v })}
/>
</div>
<Field label="Agent Prompt 重新注入间隔" hint="每多少轮对话重新注入系统提示">
<input
type="number"
value={config.gateway.agent_prompt_reinject_every}
onChange={(e) =>
update('gateway', { ...config.gateway, agent_prompt_reinject_every: +e.target.value })
}
className={inputCls}
/>
</Field>
<Field label="最大并发请求数">
<input
type="number"
value={config.gateway.max_concurrent_requests}
onChange={(e) =>
update('gateway', { ...config.gateway, max_concurrent_requests: +e.target.value })
}
className={inputCls}
/>
</Field>
<Field label="Session TTL (小时)" hint="留空表示不过期">
<input
type="number"
value={config.gateway.session_ttl_hours ?? ''}
onChange={(e) => {
const v = e.target.value;
update('gateway', { ...config.gateway, session_ttl_hours: v ? +v : undefined });
}}
className={inputCls}
placeholder="24"
/>
</Field>
</SectionCard>
</div>
);
}

View File

@ -0,0 +1,37 @@
// ImageTab - 图片上下文配置
import { Field, SectionCard } from '../ui';
import { inputCls } from '../constants';
import type { TabProps } from '../shared';
export function ImageTab({ config, update }: TabProps) {
return (
<SectionCard title="图片上下文">
<Field label="上下文中最大图片数" hint="发送给模型的图片数量上限">
<input
type="number"
value={config.image_context.max_images_in_context}
onChange={(e) =>
update('image_context', {
...config.image_context,
max_images_in_context: +e.target.value,
})
}
className={inputCls}
/>
</Field>
<Field label="图片最大存活轮次" hint="超过此轮次后不再提交给模型">
<input
type="number"
value={config.image_context.max_image_age_rounds}
onChange={(e) =>
update('image_context', {
...config.image_context,
max_image_age_rounds: +e.target.value,
})
}
className={inputCls}
/>
</Field>
</SectionCard>
);
}

View File

@ -0,0 +1,240 @@
// McpTab - MCP 服务器配置Map 编辑器 + 状态刷新)
import { useState, useEffect, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import { Field, Toggle, MapEntryHeader } from '../ui';
import { inputCls, selectCls } from '../constants';
import { AddButton } from '../shared';
import { useMapEditor } from '../useMapEditor';
import { getMcpStatus } from '../../../api/mcp';
import type { TabProps, ToastProps } from '../shared';
import type { McpServerConfig, McpStatusResponse } from '../types';
type Props = TabProps & ToastProps;
export function McpTab({ config, update, setToast }: Props) {
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null);
const editors = useMapEditor<McpServerConfig>(config.mcpServers, (v) => update('mcpServers', v));
const fetchMcpStatus = useCallback(async () => {
const data = await getMcpStatus();
if (data) setMcpStatus(data);
}, []);
useEffect(() => {
fetchMcpStatus();
}, [fetchMcpStatus]);
const addMcp = () => {
const name = prompt('MCP 服务器名称(仅字母、数字、下划线、连字符):')?.trim();
if (!name) return;
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
setToast('名称只能包含字母、数字、下划线和连字符');
return;
}
if (editors.has(name)) {
setToast('该名称已存在');
return;
}
editors.add(name, { type: 'stdio', is_active: true, command: '', args: [] });
};
const delMcp = (name: string) => {
if (confirm(`删除 MCP 服务器 "${name}"?`)) editors.remove(name);
};
const renameMcp = (oldName: string, newName: string) => {
const trimmed = newName.trim();
if (trimmed === oldName || !trimmed) return;
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
setToast('名称只能包含字母、数字、下划线和连字符');
return;
}
if (editors.has(trimmed)) {
setToast('该名称已存在');
return;
}
editors.rename(oldName, trimmed);
};
const statusFor = (key: string) => mcpStatus?.servers?.find((s) => s.key === key);
return (
<div className="space-y-4">
{mcpStatus && mcpStatus.enabled && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-[var(--bg-tertiary)] text-xs">
<div className="flex items-center gap-1.5">
<span
className={`inline-block w-2 h-2 rounded-full ${mcpStatus.connected_servers > 0 ? 'bg-green-400' : 'bg-gray-400'}`}
/>
<span className="text-[var(--text-secondary)]">
{mcpStatus.connected_servers}/{mcpStatus.total_servers}
</span>
</div>
{mcpStatus.failed_servers > 0 && (
<span className="text-red-400">{mcpStatus.failed_servers} </span>
)}
<span className="text-[var(--text-muted)]">{mcpStatus.total_tools} </span>
<button
onClick={fetchMcpStatus}
className="ml-auto px-2 py-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
title="刷新状态"
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
)}
{editors.entries.map(([name, s]) => {
const st = statusFor(name);
return (
<div
key={name}
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
>
{st && (
<div className="flex items-center gap-2 px-4 py-2">
{st.connected ? (
<span className="inline-flex items-center gap-1 text-xs text-green-400">
<span className="w-2 h-2 rounded-full bg-green-400" /> {st.tool_count}
</span>
) : st.error ? (
<span
className="inline-flex items-center gap-1 text-xs text-red-400"
title={st.error}
>
<span className="w-2 h-2 rounded-full bg-red-400" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
<span className="w-2 h-2 rounded-full bg-gray-400" />
</span>
)}
</div>
)}
<MapEntryHeader
name={name}
onDelete={() => delMcp(name)}
onRename={(n) => renameMcp(name, n)}
/>
<div className="p-4 space-y-3">
<Field label="传输类型">
<select
value={s.type}
onChange={(e) =>
editors.patch(name, { type: e.target.value as McpServerConfig['type'] })
}
className={selectCls}
>
<option value="stdio">stdio ()</option>
<option value="streamableHttp">streamableHttp (HTTP)</option>
</select>
</Field>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"></span>
<Toggle
checked={s.is_active}
onChange={(v) => editors.patch(name, { is_active: v })}
/>
</div>
<Field label="描述">
<input
value={s.description ?? ''}
onChange={(e) =>
editors.patch(name, { description: e.target.value || undefined })
}
className={inputCls}
placeholder="可选描述"
/>
</Field>
{s.type === 'stdio' && (
<>
<Field label="命令" hint="如 npx, node, cargo, uv">
<input
value={s.command ?? ''}
onChange={(e) => editors.patch(name, { command: e.target.value })}
className={inputCls}
placeholder="npx"
/>
</Field>
<Field label="参数" hint="空格分隔">
<input
value={(s.args ?? []).join(' ')}
onChange={(e) =>
editors.patch(name, {
args: e.target.value ? e.target.value.split(/\s+/) : [],
})
}
className={inputCls}
placeholder="-y @modelcontextprotocol/server-filesystem /tmp"
/>
</Field>
<Field
label="工作目录 (cwd)"
hint="可选。子进程运行目录,常用于 uv/python 项目解析 pyproject.toml 或 venv"
>
<input
value={s.cwd ?? ''}
onChange={(e) => editors.patch(name, { cwd: e.target.value || undefined })}
className={inputCls}
placeholder="E:\code_project\my-mcp-server"
/>
</Field>
<Field label="环境变量" hint="KEY=VALUE每行一个">
<textarea
value={Object.entries(s.env ?? {})
.map(([k, v]) => `${k}=${v}`)
.join('\n')}
onChange={(e) => {
const lines = e.target.value.split('\n').filter((l) => l.includes('='));
const env: Record<string, string> = {};
lines.forEach((l) => {
const [k, ...rest] = l.split('=');
if (k) env[k.trim()] = rest.join('=').trim();
});
editors.patch(name, {
env: Object.keys(env).length > 0 ? env : undefined,
});
}}
className={inputCls + ' min-h-[60px] resize-y font-mono text-xs'}
placeholder="API_KEY=xxx"
/>
</Field>
</>
)}
{(s.type === 'streamableHttp' || s.type === 'http') && (
<>
<Field label="Base URL">
<input
value={s.base_url ?? ''}
onChange={(e) => editors.patch(name, { base_url: e.target.value })}
className={inputCls}
placeholder="http://localhost:3000/mcp"
/>
</Field>
<Field label="请求头" hint="KEY=VALUE每行一个支持 ${ENV_VAR}">
<textarea
value={Object.entries(s.headers ?? {})
.map(([k, v]) => `${k}=${v}`)
.join('\n')}
onChange={(e) => {
const lines = e.target.value.split('\n').filter((l) => l.includes('='));
const headers: Record<string, string> = {};
lines.forEach((l) => {
const [k, ...rest] = l.split('=');
if (k) headers[k.trim()] = rest.join('=').trim();
});
editors.patch(name, {
headers: Object.keys(headers).length > 0 ? headers : undefined,
});
}}
className={inputCls + ' min-h-[60px] resize-y font-mono text-xs'}
placeholder="Authorization=Bearer ${TOKEN}"
/>
</Field>
</>
)}
</div>
</div>
);
})}
<AddButton label="添加 MCP 服务器" onClick={addMcp} />
</div>
);
}

View File

@ -0,0 +1,53 @@
// MemoryTab - 记忆维护配置
import { Field, SectionCard } from '../ui';
import { inputCls } from '../constants';
import type { TabProps } from '../shared';
export function MemoryTab({ config, update }: TabProps) {
return (
<SectionCard title="记忆维护">
<Field label="最大合并比例" hint="0.0 - 1.0,单次最多合并/删除的记忆比例">
<input
type="number"
step="0.05"
min="0"
max="1"
value={config.memory_maintenance.max_merge_ratio}
onChange={(e) =>
update('memory_maintenance', {
...config.memory_maintenance,
max_merge_ratio: +e.target.value,
})
}
className={inputCls}
/>
</Field>
<Field label="最小保留记忆数">
<input
type="number"
value={config.memory_maintenance.min_memories_to_keep}
onChange={(e) =>
update('memory_maintenance', {
...config.memory_maintenance,
min_memories_to_keep: +e.target.value,
})
}
className={inputCls}
/>
</Field>
<Field label="单组最大合并数">
<input
type="number"
value={config.memory_maintenance.max_merge_per_group}
onChange={(e) =>
update('memory_maintenance', {
...config.memory_maintenance,
max_merge_per_group: +e.target.value,
})
}
className={inputCls}
/>
</Field>
</SectionCard>
);
}

View File

@ -0,0 +1,89 @@
// ModelsTab - 模型配置Map 编辑器模式)
import { Field, MapEntryHeader } from '../ui';
import { inputCls } from '../constants';
import { AddButton } from '../shared';
import { useMapEditor } from '../useMapEditor';
import type { TabProps } from '../shared';
import type { ModelConfig } from '../types';
export function ModelsTab({ config, update }: TabProps) {
const editors = useMapEditor<ModelConfig>(config.models, (v) => update('models', v));
const addModel = () => {
const name = prompt('Model 名称:')?.trim();
if (name && !editors.has(name)) {
editors.add(name, { model_id: name });
}
};
const delModel = (name: string) => {
if (confirm(`删除 Model "${name}"?`)) editors.remove(name);
};
return (
<div className="space-y-4">
{editors.entries.map(([name, m]) => (
<div
key={name}
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
>
<MapEntryHeader name={name} onDelete={() => delModel(name)} />
<div className="p-4 space-y-3">
<Field label="Model ID">
<input
value={m.model_id}
onChange={(e) => editors.patch(name, { model_id: e.target.value })}
className={inputCls}
/>
</Field>
<Field
label="Temperature"
hint="控制回复随机性0 表示确定性输出,值越大越随机。留空使用模型默认值"
>
<input
type="number"
step="0.1"
value={m.temperature ?? ''}
onChange={(e) =>
editors.patch(name, { temperature: e.target.value ? +e.target.value : undefined })
}
className={inputCls}
placeholder="0.7"
/>
</Field>
<Field
label="Max Tokens"
hint="模型单次回复最大生成 token 数,超出会被截断。留空使用模型默认值(如 4096/8192"
>
<input
type="number"
value={m.max_tokens ?? ''}
onChange={(e) =>
editors.patch(name, { max_tokens: e.target.value ? +e.target.value : undefined })
}
className={inputCls}
placeholder="4096"
/>
</Field>
<Field
label="Context Window Tokens"
hint="模型上下文窗口大小,用于内部历史消息压缩/裁剪计算。留空默认 128000"
>
<input
type="number"
value={m.context_window_tokens ?? ''}
onChange={(e) =>
editors.patch(name, {
context_window_tokens: e.target.value ? +e.target.value : undefined,
})
}
className={inputCls}
placeholder="128000"
/>
</Field>
</div>
</div>
))}
<AddButton label="添加模型" onClick={addModel} />
</div>
);
}

View File

@ -0,0 +1,97 @@
// ProvidersTab - 服务商配置Map 编辑器模式)
import { Field, MapEntryHeader } from '../ui';
import { inputCls, selectCls } from '../constants';
import { AddButton } from '../shared';
import { useMapEditor } from '../useMapEditor';
import type { TabProps } from '../shared';
import type { ProviderConfig } from '../types';
export function ProvidersTab({ config, update }: TabProps) {
const editors = useMapEditor<ProviderConfig>(config.providers, (v) => update('providers', v));
const addProvider = () => {
const name = prompt('Provider 名称:')?.trim();
if (name && !editors.has(name)) {
editors.add(name, {
type: 'openai',
base_url: '',
api_key: '',
extra_headers: {},
llm_timeout_secs: 120,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
});
}
};
const delProvider = (name: string) => {
if (confirm(`删除 Provider "${name}"?`)) editors.remove(name);
};
return (
<div className="space-y-4">
{editors.entries.map(([name, p]) => (
<div
key={name}
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
>
<MapEntryHeader name={name} onDelete={() => delProvider(name)} />
<div className="p-4 space-y-3">
<Field label="类型">
<select
value={p.type}
onChange={(e) => editors.patch(name, { type: e.target.value })}
className={selectCls}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</Field>
<Field label="Base URL">
<input
value={p.base_url}
onChange={(e) => editors.patch(name, { base_url: e.target.value })}
className={inputCls}
/>
</Field>
<Field label="API Key">
<input
type="password"
value={p.api_key}
onChange={(e) => editors.patch(name, { api_key: e.target.value })}
className={inputCls}
/>
</Field>
<Field label="LLM 超时 (秒)">
<input
type="number"
value={p.llm_timeout_secs}
onChange={(e) => editors.patch(name, { llm_timeout_secs: +e.target.value })}
className={inputCls}
/>
</Field>
<Field label="记忆维护超时 (秒)">
<input
type="number"
value={p.memory_maintenance_timeout_secs}
onChange={(e) =>
editors.patch(name, { memory_maintenance_timeout_secs: +e.target.value })
}
className={inputCls}
/>
</Field>
<Field label="最大重试次数">
<input
type="number"
min={0}
value={p.max_retries}
onChange={(e) => editors.patch(name, { max_retries: +e.target.value })}
className={inputCls}
/>
</Field>
</div>
</div>
))}
<AddButton label="添加服务商" onClick={addProvider} />
</div>
);
}

View File

@ -0,0 +1,56 @@
// SchedulerTab - 调度器配置
import { Field, Toggle, SectionCard } from '../ui';
import { inputCls, selectCls } from '../constants';
import type { TabProps } from '../shared';
import type { SchedulerConfig } from '../types';
export function SchedulerTab({ config, update }: TabProps) {
return (
<div className="space-y-5">
<SectionCard title="调度器">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"></span>
<Toggle
checked={config.scheduler.enabled}
onChange={(v) => update('scheduler', { ...config.scheduler, enabled: v })}
/>
</div>
<Field label="Tick 分辨率 (ms)">
<input
type="number"
value={config.scheduler.tick_resolution_ms}
onChange={(e) =>
update('scheduler', { ...config.scheduler, tick_resolution_ms: +e.target.value })
}
className={inputCls}
/>
</Field>
<Field label="工作队列容量">
<input
type="number"
value={config.scheduler.worker_queue_capacity}
onChange={(e) =>
update('scheduler', { ...config.scheduler, worker_queue_capacity: +e.target.value })
}
className={inputCls}
/>
</Field>
<Field label="Misfire 策略">
<select
value={config.scheduler.misfire_policy}
onChange={(e) =>
update('scheduler', {
...config.scheduler,
misfire_policy: e.target.value as SchedulerConfig['misfire_policy'],
})
}
className={selectCls}
>
<option value="skip"> (Skip)</option>
<option value="catch_up"> (Catch Up)</option>
</select>
</Field>
</SectionCard>
</div>
);
}

View File

@ -0,0 +1,149 @@
// SkillsTab - 技能配置(来源目录 + 已发现技能列表)
import { useState, useEffect, useCallback } from 'react';
import { Loader2 } from 'lucide-react';
import { Field, Toggle, SectionCard, SourceEditor } from '../ui';
import { inputCls } from '../constants';
import type { TabProps, ToastProps } from '../shared';
import type { KnownSource, SkillListResponse } from '../types';
import { listSkills, toggleSkill } from '../../../api/skills';
type Props = TabProps & ToastProps;
const SKILL_KNOWN_SOURCES: KnownSource[] = [
{ key: 'user', label: '用户技能', description: '~/.picobot/skills' },
{ key: 'user_agent', label: '用户 Agent 技能', description: '~/.agents/skills' },
{ key: 'user_openclaw', label: '用户 OpenClaw 技能', description: '~/.openclaw/skills' },
{ key: 'project', label: '项目技能', description: '.picobot/skills' },
{ key: 'project_agent', label: '项目 Agent 技能', description: '.agents/skills' },
{ key: 'project_openclaw', label: '项目 OpenClaw 技能', description: '.openclaw/skills' },
];
export function SkillsTab({ config, update, setToast }: Props) {
const [skillList, setSkillList] = useState<SkillListResponse | null>(null);
const [skillListLoading, setSkillListLoading] = useState(false);
const fetchSkillList = useCallback(async () => {
setSkillListLoading(true);
const data = await listSkills();
if (data) setSkillList(data);
setSkillListLoading(false);
}, []);
useEffect(() => {
fetchSkillList();
}, [fetchSkillList]);
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
if (!skillList) return;
const skills = skillList.skills;
const prevSkillList = skillList;
setSkillList({
...skillList,
skills: skills.map((s) =>
s.name === name ? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : s,
),
});
try {
const resp = await toggleSkill(name, 'project', !currentlyEnabled);
const data = await resp.json();
if (!resp.ok || !data.success) {
setSkillList(prevSkillList);
setToast(data.error || '切换技能状态失败');
return;
}
setSkillList({
...prevSkillList,
skills: prevSkillList.skills.map((s) =>
s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s,
),
});
} catch {
setSkillList(prevSkillList);
setToast('网络错误,切换技能状态失败');
}
};
return (
<div className="space-y-5">
<SectionCard title="技能系统">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"></span>
<Toggle
checked={config.skills.enabled}
onChange={(v) => update('skills', { ...config.skills, enabled: v })}
/>
</div>
<Field label="最大索引字符数">
<input
type="number"
value={config.skills.max_index_chars}
onChange={(e) =>
update('skills', { ...config.skills, max_index_chars: +e.target.value })
}
className={inputCls}
/>
</Field>
<Field label="最大展示技能数">
<input
type="number"
value={config.skills.max_listed_skills}
onChange={(e) =>
update('skills', { ...config.skills, max_listed_skills: +e.target.value })
}
className={inputCls}
/>
</Field>
</SectionCard>
<SectionCard title="来源目录">
<SourceEditor
sources={config.skills.sources}
onChange={(v) => update('skills', { ...config.skills, sources: v })}
knownSources={SKILL_KNOWN_SOURCES}
examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
/>
</SectionCard>
{skillList && skillList.skills_system_enabled && (
<SectionCard title="已发现技能" subtitle="即时生效">
{skillListLoading && skillList.skills.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : skillList.skills.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{skillList.skills.map((skill) => {
const isEnabled = skill.disabled_in_scopes.length === 0;
return (
<div
key={skill.name}
className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">
{skill.name}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">
{skill.source}
</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
{skill.description}
</p>
</div>
<Toggle
checked={isEnabled}
onChange={() => handleToggle(skill.name, isEnabled)}
/>
</div>
);
})}
</div>
)}
</SectionCard>
)}
</div>
);
}

View File

@ -0,0 +1,270 @@
// SubagentsTab - 子代理配置(来源目录 + 已发现子代理 + 编辑/创建/删除 + 搜索)
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Loader2, Pencil, Trash2, Search } from 'lucide-react';
import { Toggle, SectionCard, SourceEditor } from '../ui';
import { AddButton } from '../shared';
import {
SubagentModal,
subagentToDraft,
emptySubagentDraft,
type SubagentDraft,
} from '../modals/SubagentModal';
import { listSubagents, toggleSubagent, deleteSubagent } from '../../../api/subagents';
import type { TabProps, ToastProps } from '../shared';
import type { KnownSource, SubagentListResponse } from '../types';
type Props = TabProps & ToastProps;
const SUBAGENT_KNOWN_SOURCES: KnownSource[] = [
{ key: 'user', label: '用户子代理', description: '~/.picobot/subagents' },
{ key: 'project', label: '项目子代理', description: '.picobot/subagents' },
];
// 工具名 → 友好标签(用于在列表项展示 allowed/denied 工具)
const KNOWN_TOOL_LABELS: Record<string, string> = {
read: 'Read',
edit: 'Edit',
write: 'Write',
bash: 'Bash',
http_request: 'HTTP Request',
web_fetch: 'Web Fetch',
memory_search: 'Memory Search',
get_time: 'Get Time',
calculator: 'Calculator',
skill_activate: 'Skill Activate',
skill_list: 'Skill List',
send_session_message: 'Send Session Message',
};
function toolLabel(key: string): string {
return KNOWN_TOOL_LABELS[key] ?? key;
}
function ToolTag({
label,
tools,
tone,
}: {
label: string;
tools: string[] | undefined;
tone: 'allow' | 'deny';
}) {
if (!tools || tools.length === 0) return null;
const tagCls =
tone === 'allow'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400';
return (
<div className="flex items-center gap-1 flex-wrap mt-1">
<span className="text-[10px] text-[var(--text-muted)]">{label}:</span>
{tools.map((t) => (
<span key={t} className={`text-[10px] px-1.5 py-0.5 rounded ${tagCls}`}>
{toolLabel(t)}
</span>
))}
</div>
);
}
function ModelTag({ provider, model }: { provider?: string; model?: string }) {
if (!provider && !model) return null;
const text = [provider, model].filter(Boolean).join(' / ');
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400">
{text}
</span>
);
}
export function SubagentsTab({ config, update, setToast }: Props) {
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null);
const [subagentListLoading, setSubagentListLoading] = useState(false);
const [editingSubagent, setEditingSubagent] = useState<SubagentDraft | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const fetchSubagentList = useCallback(async () => {
setSubagentListLoading(true);
const data = await listSubagents();
if (data) setSubagentList(data);
setSubagentListLoading(false);
}, []);
useEffect(() => {
fetchSubagentList();
}, [fetchSubagentList]);
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
if (!subagentList) return;
const prevList = subagentList;
setSubagentList({
...subagentList,
subagents: subagentList.subagents.map((s) =>
s.name === name ? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : s,
),
});
try {
const resp = await toggleSubagent(name, 'project', !currentlyEnabled);
const data = await resp.json();
if (!resp.ok || !data.success) {
setSubagentList(prevList);
setToast(data.error || '切换子代理状态失败');
return;
}
setSubagentList({
...prevList,
subagents: prevList.subagents.map((s) =>
s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s,
),
});
} catch {
setSubagentList(prevList);
setToast('网络错误,切换子代理状态失败');
}
};
const handleDelete = async (name: string) => {
if (!confirm(`确定删除子代理 "${name}" 吗?此操作将删除对应文件。`)) return;
try {
const resp = await deleteSubagent(name);
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
setToast(data.error || '删除子代理失败');
return;
}
setToast('子代理已删除');
fetchSubagentList();
} catch {
setToast('网络错误,删除子代理失败');
}
};
// 按名称/描述过滤
const filteredSubagents = useMemo(() => {
if (!subagentList) return [];
const q = searchQuery.trim().toLowerCase();
if (!q) return subagentList.subagents;
return subagentList.subagents.filter(
(s) =>
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
);
}, [subagentList, searchQuery]);
return (
<div className="space-y-5">
<SectionCard title="子代理">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"></span>
<Toggle
checked={config.subagents.enabled}
onChange={(v) => update('subagents', { ...config.subagents, enabled: v })}
/>
</div>
</SectionCard>
<SectionCard title="来源目录">
<SourceEditor
sources={config.subagents.sources}
onChange={(v) => update('subagents', { ...config.subagents, sources: v })}
knownSources={SUBAGENT_KNOWN_SOURCES}
examplePaths={['D:\\my-subagents', '/home/user/shared-agents']}
/>
</SectionCard>
{subagentList && subagentList.subagents_system_enabled && (
<SectionCard title="已发现子代理" subtitle="即时生效">
{/* 搜索框 */}
{subagentList.subagents.length > 0 && (
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--text-muted)]" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索子代理名称或描述..."
className="w-full pl-9 pr-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors"
/>
</div>
)}
{subagentListLoading && subagentList.subagents.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : subagentList.subagents.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : filteredSubagents.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{filteredSubagents.map((subagent) => {
const isEnabled = subagent.disabled_in_scopes.length === 0;
const isBuiltin = subagent.source === 'builtin';
return (
<div
key={subagent.name}
className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-mono text-[var(--text-primary)]">
{subagent.name}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">
{subagent.source}
</span>
<ModelTag provider={subagent.provider} model={subagent.model} />
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
{subagent.description}
</p>
<ToolTag
label="允许"
tools={subagent.capability?.allowed_tools}
tone="allow"
/>
<ToolTag
label="禁用"
tools={subagent.capability?.denied_tools}
tone="deny"
/>
</div>
{!isBuiltin && (
<>
<button
onClick={() => setEditingSubagent(subagentToDraft(subagent))}
className="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="编辑"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(subagent.name)}
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
<Toggle
checked={isEnabled}
onChange={() => handleToggle(subagent.name, isEnabled)}
/>
</div>
);
})}
</div>
)}
</SectionCard>
)}
<AddButton label="添加子代理" onClick={() => setEditingSubagent(emptySubagentDraft())} />
{editingSubagent && (
<SubagentModal
draft={editingSubagent}
onClose={() => setEditingSubagent(null)}
onSaved={() => {
setEditingSubagent(null);
fetchSubagentList();
}}
setToast={setToast}
/>
)}
</div>
);
}

View File

@ -0,0 +1,24 @@
// TimeTab - 时区设置
import { Field, SectionCard } from '../ui';
import { inputCls, TIMEZONE_OPTIONS } from '../constants';
import type { TabProps } from '../shared';
export function TimeTab({ config, update }: TabProps) {
return (
<SectionCard title="时区设置">
<Field label="时区" hint="IANA 格式">
<select
value={config.time.timezone}
onChange={(e) => update('time', { timezone: e.target.value })}
className={inputCls}
>
{TIMEZONE_OPTIONS.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
</Field>
</SectionCard>
);
}

View File

@ -0,0 +1,100 @@
// ToolsTab - 工具配置
import { Field, Toggle, SectionCard, TagEditor, SourceEditor } from '../ui';
import { inputCls } from '../constants';
import type { TabProps } from '../shared';
import type { KnownSource } from '../types';
const TASK_KNOWN_TOOLS: KnownSource[] = [
{ key: 'read', label: 'Read', description: '读取文件' },
{ key: 'edit', label: 'Edit', description: '编辑文件' },
{ key: 'write', label: 'Write', description: '写入文件' },
{ key: 'bash', label: 'Bash', description: '执行 Shell 命令' },
{ key: 'http_request', label: 'HTTP Request', description: '发送 HTTP 请求' },
{ key: 'web_fetch', label: 'Web Fetch', description: '抓取网页内容' },
{ key: 'memory_search', label: 'Memory Search', description: '搜索记忆' },
{ key: 'get_time', label: 'Get Time', description: '获取当前时间' },
{ key: 'calculator', label: 'Calculator', description: '计算器' },
{ key: 'skill_activate', label: 'Skill Activate', description: '激活技能' },
{ key: 'skill_list', label: 'Skill List', description: '列出技能' },
{ key: 'send_session_message', label: 'Send Session Message', description: '发送会话消息' },
];
export function ToolsTab({ config, update }: TabProps) {
return (
<div className="space-y-5">
<SectionCard title="禁用工具列表">
<TagEditor
tags={config.tools.disabled}
onChange={(v) => update('tools', { ...config.tools, disabled: v })}
/>
</SectionCard>
<SectionCard title="Task 子代理">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]"> Task </span>
<Toggle
checked={config.tools.task.enabled}
onChange={(v) =>
update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })
}
/>
</div>
<Field label="最大执行时间 (秒)">
<input
type="number"
value={config.tools.task.max_execution_secs}
onChange={(e) =>
update('tools', {
...config.tools,
task: { ...config.tools.task, max_execution_secs: +e.target.value },
})
}
className={inputCls}
/>
</Field>
<Field label="TTL (小时)">
<input
type="number"
value={config.tools.task.ttl_hours}
onChange={(e) =>
update('tools', {
...config.tools,
task: { ...config.tools.task, ttl_hours: +e.target.value },
})
}
className={inputCls}
/>
</Field>
<Field
label="最大嵌套深度"
hint="允许的子代理最大嵌套层数。1=仅子代理2=子代理+孙代理默认0=禁止嵌套。深度达上限时移除 task 工具以防无限递归"
>
<input
type="number"
min={0}
value={config.tools.task.max_nesting_depth}
onChange={(e) =>
update('tools', {
...config.tools,
task: {
...config.tools.task,
max_nesting_depth: Math.max(0, +e.target.value || 0),
},
})
}
className={inputCls}
/>
</Field>
</SectionCard>
<SectionCard title="允许的工具列表">
<SourceEditor
sources={config.tools.task.allowed_tools}
onChange={(v) =>
update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })
}
knownSources={TASK_KNOWN_TOOLS}
showCustom={false}
/>
</SectionCard>
</div>
);
}

View File

@ -137,6 +137,8 @@ export interface SubagentItem {
capability?: CapabilityPolicy; capability?: CapabilityPolicy;
provider?: string; provider?: string;
model?: string; model?: string;
/** SUBAGENT.md 的 markdown 正文。builtin 子代理可能为 undefined。 */
body?: string;
} }
export interface SubagentListResponse { export interface SubagentListResponse {

View File

@ -0,0 +1,69 @@
// 通用 Map 编辑 hook封装 providers/models/agents/mcp/channels 五处重复的 add/del/rename/upd 模式
import { useCallback } from 'react';
export interface MapEditor<T> {
entries: [string, T][];
add: (name: string, value: T) => void;
remove: (name: string) => void;
rename: (oldName: string, newName: string) => void;
patch: (name: string, partial: Partial<T>) => void;
has: (name: string) => boolean;
}
/**
*
* const editors = useMapEditor(config.providers, (v) => update('providers', v));
* editors.add('openai', { type: 'openai', ... });
* editors.patch('openai', { api_key: 'sk-xxx' });
*
* rename map key
*/
export function useMapEditor<T>(
map: Record<string, T>,
setMap: (next: Record<string, T>) => void,
): MapEditor<T> {
const entries = Object.entries(map);
const add = useCallback(
(name: string, value: T) => {
if (map[name]) return;
setMap({ ...map, [name]: value });
},
[map, setMap],
);
const remove = useCallback(
(name: string) => {
const { [name]: _omit, ...rest } = map;
void _omit;
setMap(rest);
},
[map, setMap],
);
const rename = useCallback(
(oldName: string, newName: string) => {
const trimmed = newName.trim();
if (trimmed === oldName || !trimmed || map[trimmed]) return;
const next: Record<string, T> = {};
for (const [k, v] of Object.entries(map)) {
next[k === oldName ? trimmed : k] = v;
}
setMap(next);
},
[map, setMap],
);
const patch = useCallback(
(name: string, partial: Partial<T>) => {
const cur = map[name];
if (!cur) return;
setMap({ ...map, [name]: { ...cur, ...partial } });
},
[map, setMap],
);
const has = useCallback((name: string) => !!map[name], [map]);
return { entries, add, remove, rename, patch, has };
}

View File

@ -0,0 +1,55 @@
// 共享数据 hook专家/子代理模态框打开时加载能力勾选列表所需的数据
// 在模态框挂载时触发,避免在 ConfigPage 顶层预加载
import { useEffect, useState } from 'react';
import { listSkills } from '../../api/skills';
import { listTools } from '../../api/tools';
import { listSubagents } from '../../api/subagents';
import { listModelOptions } from '../../api/experts';
import type {
SkillListResponse,
ToolsListResponse,
SubagentListResponse,
ModelOptionsResponse,
} from './types';
export interface SharedModalData {
skillList: SkillListResponse | null;
toolList: ToolsListResponse | null;
subagentList: SubagentListResponse | null;
modelOptions: ModelOptionsResponse | null;
loading: boolean;
}
/**
* skills/tools/subagents/modelOptions
* / provider/model
*/
export function useSharedModalData(enabled: boolean): SharedModalData {
const [skillList, setSkillList] = useState<SkillListResponse | null>(null);
const [toolList, setToolList] = useState<ToolsListResponse | null>(null);
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null);
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setLoading(true);
Promise.all([listSkills(), listTools(), listSubagents(), listModelOptions()])
.then(([s, t, sub, m]) => {
if (cancelled) return;
if (s) setSkillList(s);
if (t) setToolList(t);
if (sub) setSubagentList(sub);
if (m) setModelOptions(m);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled]);
return { skillList, toolList, subagentList, modelOptions, loading };
}