feat(02-01): 统一 skills bootstrap 合同到 content_ids
- 新增 content_id 到 content_ids 最小兼容归一层 - 调用侧统一发送 content_ids,移除双主合同路径
This commit is contained in:
parent
28ab2ac39f
commit
5edd0cd9ab
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type { Skill } from "./type";
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue