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 { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
import type { Skill } from "./type";
|
import {
|
||||||
|
normalizeBootstrapRemoteSkillRequest,
|
||||||
|
} from "./normalize-bootstrap";
|
||||||
|
import type { Skill } from "./types";
|
||||||
|
|
||||||
export async function loadSkills() {
|
export async function loadSkills() {
|
||||||
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
|
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
|
||||||
|
|
@ -35,9 +38,26 @@ export interface InstallSkillResponse {
|
||||||
message: string;
|
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 {
|
export interface BootstrapRemoteSkillRequest {
|
||||||
thread_id: string;
|
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;
|
language_type?: number;
|
||||||
target_dir?: string;
|
target_dir?: string;
|
||||||
clear_target?: boolean;
|
clear_target?: boolean;
|
||||||
|
|
@ -46,10 +66,9 @@ export interface BootstrapRemoteSkillRequest {
|
||||||
export interface BootstrapRemoteSkillResponse {
|
export interface BootstrapRemoteSkillResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
target_dir: string;
|
target_dir: string;
|
||||||
content_ids: number[];
|
|
||||||
created_directories: number;
|
created_directories: number;
|
||||||
created_files: number;
|
created_files: number;
|
||||||
sandbox_id: string | null;
|
sandbox_id: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,11 +98,11 @@ export async function installSkill(
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bootstrapRemoteSkill(
|
export async function materializeSkillYaml(
|
||||||
request: BootstrapRemoteSkillRequest,
|
request: MaterializeSkillYamlRequest,
|
||||||
): Promise<BootstrapRemoteSkillResponse> {
|
): Promise<MaterializeSkillYamlResponse> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/skills/bootstrap-remote`,
|
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -102,3 +121,28 @@ export async function bootstrapRemoteSkill(
|
||||||
|
|
||||||
return response.json();
|
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