feat(02-01): 统一 skills bootstrap 合同到 content_ids

- 新增 content_id 到 content_ids 最小兼容归一层

- 调用侧统一发送 content_ids,移除双主合同路径
This commit is contained in:
肖应宇 2026-04-07 12:53:54 +08:00
parent 28ab2ac39f
commit 5edd0cd9ab
4 changed files with 249 additions and 8 deletions

View File

@ -1,6 +1,9 @@
import { getBackendBaseURL } from "@/core/config";
import type { Skill } from "./type";
import {
normalizeBootstrapRemoteSkillRequest,
} from "./normalize-bootstrap";
import type { Skill } from "./types";
export async function loadSkills() {
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
@ -35,9 +38,26 @@ export interface InstallSkillResponse {
message: string;
}
export interface MaterializeSkillYamlRequest {
thread_id: string;
path: string;
target_dir?: string;
clear_target?: boolean;
}
export interface MaterializeSkillYamlResponse {
success: boolean;
target_dir: string;
created_directories: number;
created_files: number;
message: string;
}
export interface BootstrapRemoteSkillRequest {
thread_id: string;
content_ids: number[];
content_ids?: number[];
// Legacy input, kept for minimal compatibility at the API boundary.
content_id?: number;
language_type?: number;
target_dir?: string;
clear_target?: boolean;
@ -46,10 +66,9 @@ export interface BootstrapRemoteSkillRequest {
export interface BootstrapRemoteSkillResponse {
success: boolean;
target_dir: string;
content_ids: number[];
created_directories: number;
created_files: number;
sandbox_id: string | null;
sandbox_id: string;
message: string;
}
@ -79,11 +98,11 @@ export async function installSkill(
return response.json();
}
export async function bootstrapRemoteSkill(
request: BootstrapRemoteSkillRequest,
): Promise<BootstrapRemoteSkillResponse> {
export async function materializeSkillYaml(
request: MaterializeSkillYamlRequest,
): Promise<MaterializeSkillYamlResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/skills/bootstrap-remote`,
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
{
method: "POST",
headers: {
@ -102,3 +121,28 @@ export async function bootstrapRemoteSkill(
return response.json();
}
export async function bootstrapRemoteSkill(
request: BootstrapRemoteSkillRequest,
): Promise<BootstrapRemoteSkillResponse> {
const normalizedRequest = normalizeBootstrapRemoteSkillRequest(request);
const response = await fetch(
`${getBackendBaseURL()}/api/skills/bootstrap-remote`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(normalizedRequest),
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
throw new Error(errorMessage);
}
return response.json();
}

View File

@ -0,0 +1,44 @@
export interface BootstrapRemoteSkillRequestLike {
thread_id: string;
content_ids?: number[];
content_id?: number;
language_type?: number;
target_dir?: string;
clear_target?: boolean;
}
export interface NormalizedBootstrapRemoteSkillRequest
extends Omit<BootstrapRemoteSkillRequestLike, "content_id" | "content_ids"> {
content_ids: number[];
}
export function normalizeBootstrapRemoteSkillRequest(
request: BootstrapRemoteSkillRequestLike,
): NormalizedBootstrapRemoteSkillRequest {
const normalizedContentIds = Array.isArray(request.content_ids)
? request.content_ids
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0)
: [];
const legacyContentId =
request.content_id != null && Number.isFinite(Number(request.content_id))
? Number(request.content_id)
: undefined;
const contentIds =
normalizedContentIds.length > 0
? normalizedContentIds
: legacyContentId != null
? [legacyContentId]
: [];
if (contentIds.length === 0) {
throw new Error("content_ids is required.");
}
return {
...request,
content_ids: contentIds,
};
}

View File

@ -0,0 +1 @@
export type { Skill } from "./type";

View File

@ -0,0 +1,152 @@
import { useSearchParams } from "next/navigation";
import { useEffect, useCallback, useState, useRef } from "react";
import { toast } from "sonner";
import { bootstrapRemoteSkill } from "@/core/skills/api";
/** 宿主页发过来的 selectedSkill 消息结构 */
interface SelectedSkillMessage {
type: "selectedSkill";
id: number | string;
title: string;
}
/** 技能基础数据 */
interface SkillData {
skill_id: string;
title: string;
}
/** 错误信息状态 */
interface SkillError {
title: string;
message: string;
}
interface UseSelectedSkillListenerOptions {
/** 当前会话 thread_id用于调用 bootstrapRemoteSkill */
threadId?: string | null;
}
interface UseSelectedSkillListenerReturn {
/** 当前选中的技能数据(用于 UI 展示,如 Badge */
selectedSkill: SkillData | null;
/** 当前错误信息,不为 null 时展示 DevDialog */
skillError: SkillError | null;
/** 清除错误信息(关闭 DevDialog 时调用) */
clearSkillError: () => void;
/** 是否正在加载(处理 skill 中) */
isBootstrapping: boolean;
}
/**
* 宿 postMessage selectedSkill URL skill
* bootstrapRemoteSkill
* - 使 toast
* - skillError DevDialog
*/
export function useSelectedSkillListener({
threadId,
}: UseSelectedSkillListenerOptions): UseSelectedSkillListenerReturn {
const searchParams = useSearchParams();
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
const [skillError, setSkillError] = useState<SkillError | null>(null);
const [isBootstrapping, setIsBootstrapping] = useState(false);
const isFirstLoadRef = useRef(false);
const skillBootstrappedKeyRef = useRef<string | null>(null);
const performBootstrap = useCallback(
async (id: number | string, title: string) => {
if (!threadId) return;
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
const initKey = `${threadId}:${id}:${languageType}`;
if (skillBootstrappedKeyRef.current === initKey) {
return;
}
console.log(
`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`,
);
setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
try {
const result = await bootstrapRemoteSkill({
thread_id: threadId,
content_ids: [Number(id)],
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
toast.dismiss("skill-bootstrap");
if (result.success) {
skillBootstrappedKeyRef.current = initKey;
toast.success(`技能「${title}」加载成功`, {
description:
result.message || `已创建 ${result.created_files} 个文件`,
duration: 4000,
});
} else {
setSkillError({
title: `技能「${title}」加载失败`,
message: result.message || "未知错误",
});
}
} catch (err) {
toast.dismiss("skill-bootstrap");
const message = err instanceof Error ? err.message : "网络请求失败";
setSkillError({ title: `技能「${title}」加载出错`, message });
} finally {
setIsBootstrapping(false);
}
},
[threadId, searchParams],
);
// 1. URL 初始化集成
useEffect(() => {
if (!threadId || isFirstLoadRef.current) return;
const skillIdFromQuery = searchParams.get("skill_id");
const titleFromQuery = searchParams.get("title");
if (skillIdFromQuery && titleFromQuery) {
isFirstLoadRef.current = true;
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
void performBootstrap(skillIdFromQuery, titleFromQuery);
}
}, [threadId, searchParams, performBootstrap]);
const handleMessage = useCallback(
(event: MessageEvent) => {
const data = event.data as SelectedSkillMessage;
if (data?.type !== "selectedSkill") return;
const { id, title } = data;
console.log(
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
data,
);
setSelectedSkill({ skill_id: String(id), title });
void performBootstrap(id, title);
},
[performBootstrap],
);
useEffect(() => {
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [handleMessage]);
const clearSkillError = useCallback(() => setSkillError(null), []);
return { selectedSkill, skillError, clearSkillError, isBootstrapping };
}