mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.74.0 - Chrome Bridge 浏览器自动化集成
🎉 重大功能 - Chrome Bridge: 完整的浏览器自动化控制系统 - 零配置自动连接:扩展自动加载和配置 - AI 原生集成:作为 MCP 工具支持自然语言控制 - 支持导航、页面读取、元素交互、表单操作等 🐛 Bug 修复 - WebSocket 路由修复 (Axum 路径参数语法) - Chrome 扩展存储清理 - 扩展重复注入防护 - 剪贴板权限添加 🔧 代码质量 - 修复 33+ Clippy 警告 - 所有 259 个测试通过 - ESLint 无警告 📝 文档 - Chrome Bridge 使用指南 - API 技术文档 - 快速参考卡片
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 小说编排 API
|
||||
*
|
||||
* 对应 src-tauri/src/commands/novel_cmd.rs
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
normalizeNovelSettingsEnvelope,
|
||||
type NovelSettingsEnvelope,
|
||||
type NovelSettingsV1,
|
||||
} from "@/lib/novel-settings/types";
|
||||
|
||||
export interface NovelProject {
|
||||
id: string;
|
||||
title: string;
|
||||
theme?: string | null;
|
||||
target_words: number;
|
||||
status: string;
|
||||
current_word_count: number;
|
||||
metadata_json?: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NovelSettingsRecord {
|
||||
id: string;
|
||||
project_id: string;
|
||||
settings_json: NovelSettingsEnvelope;
|
||||
version: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface NovelOutlineRecord {
|
||||
id: string;
|
||||
project_id: string;
|
||||
outline_markdown: string;
|
||||
outline_json?: Record<string, unknown> | null;
|
||||
version: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface NovelCharacterRecord {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
role_type: string;
|
||||
card_json: Record<string, unknown>;
|
||||
version: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NovelChapterRecord {
|
||||
id: string;
|
||||
project_id: string;
|
||||
chapter_no: number;
|
||||
title: string;
|
||||
content: string;
|
||||
word_count: number;
|
||||
status: string;
|
||||
quality_score?: number | null;
|
||||
metadata_json?: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NovelGenerationRun {
|
||||
id: string;
|
||||
project_id: string;
|
||||
mode: string;
|
||||
input_snapshot_json?: Record<string, unknown> | null;
|
||||
output_snapshot_json?: Record<string, unknown> | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
latency_ms?: number | null;
|
||||
token_usage_json?: Record<string, unknown> | null;
|
||||
result_status: string;
|
||||
error_message?: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface NovelConsistencyIssue {
|
||||
level: "info" | "warn" | "error" | string;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NovelConsistencyCheck {
|
||||
id: string;
|
||||
project_id: string;
|
||||
chapter_id: string;
|
||||
issues: NovelConsistencyIssue[];
|
||||
score: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface NovelProjectSnapshot {
|
||||
project: NovelProject;
|
||||
latest_settings?: NovelSettingsRecord | null;
|
||||
latest_outline?: NovelOutlineRecord | null;
|
||||
characters: NovelCharacterRecord[];
|
||||
chapters: NovelChapterRecord[];
|
||||
latest_consistency?: NovelConsistencyCheck | null;
|
||||
}
|
||||
|
||||
export interface NovelGenerateResult {
|
||||
mode: string;
|
||||
run_id: string;
|
||||
content: string;
|
||||
chapter?: NovelChapterRecord | null;
|
||||
}
|
||||
|
||||
export interface CreateNovelProjectRequest {
|
||||
id?: string;
|
||||
title: string;
|
||||
theme?: string;
|
||||
target_words?: number;
|
||||
metadata_json?: Record<string, unknown>;
|
||||
settings_json?: NovelSettingsEnvelope | NovelSettingsV1 | Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateNovelSettingsRequest {
|
||||
project_id: string;
|
||||
settings_json: NovelSettingsEnvelope | NovelSettingsV1 | Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NovelGenerateRequest {
|
||||
project_id: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
|
||||
export interface NovelGenerateChapterRequest extends NovelGenerateRequest {
|
||||
chapter_no?: number;
|
||||
force_overwrite?: boolean;
|
||||
}
|
||||
|
||||
export interface NovelRewriteChapterRequest extends NovelGenerateRequest {
|
||||
chapter_id: string;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
export interface NovelPolishChapterRequest extends NovelGenerateRequest {
|
||||
chapter_id: string;
|
||||
focus?: string;
|
||||
}
|
||||
|
||||
export interface NovelCheckConsistencyRequest {
|
||||
project_id: string;
|
||||
chapter_id: string;
|
||||
}
|
||||
|
||||
export interface NovelListRunsRequest {
|
||||
project_id: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function createNovelProject(
|
||||
request: CreateNovelProjectRequest,
|
||||
): Promise<NovelProject> {
|
||||
const normalizedRequest =
|
||||
request.settings_json === undefined
|
||||
? request
|
||||
: {
|
||||
...request,
|
||||
settings_json: normalizeNovelSettingsEnvelope(request.settings_json),
|
||||
};
|
||||
return invoke("novel_create_project", { request: normalizedRequest });
|
||||
}
|
||||
|
||||
export async function updateNovelSettings(
|
||||
request: UpdateNovelSettingsRequest,
|
||||
): Promise<NovelSettingsRecord> {
|
||||
return invoke("novel_update_settings", {
|
||||
request: {
|
||||
...request,
|
||||
settings_json: normalizeNovelSettingsEnvelope(request.settings_json),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateNovelOutline(
|
||||
request: NovelGenerateRequest,
|
||||
): Promise<NovelGenerateResult> {
|
||||
return invoke("novel_generate_outline", { request });
|
||||
}
|
||||
|
||||
export async function generateNovelCharacters(
|
||||
request: NovelGenerateRequest,
|
||||
): Promise<NovelGenerateResult> {
|
||||
return invoke("novel_generate_characters", { request });
|
||||
}
|
||||
|
||||
export async function generateNovelChapter(
|
||||
request: NovelGenerateChapterRequest,
|
||||
): Promise<NovelGenerateResult> {
|
||||
return invoke("novel_generate_chapter", { request });
|
||||
}
|
||||
|
||||
export async function continueNovelChapter(
|
||||
request: NovelGenerateRequest,
|
||||
): Promise<NovelGenerateResult> {
|
||||
return invoke("novel_continue_chapter", { request });
|
||||
}
|
||||
|
||||
export async function rewriteNovelChapter(
|
||||
request: NovelRewriteChapterRequest,
|
||||
): Promise<NovelGenerateResult> {
|
||||
return invoke("novel_rewrite_chapter", { request });
|
||||
}
|
||||
|
||||
export async function polishNovelChapter(
|
||||
request: NovelPolishChapterRequest,
|
||||
): Promise<NovelGenerateResult> {
|
||||
return invoke("novel_polish_chapter", { request });
|
||||
}
|
||||
|
||||
export async function checkNovelConsistency(
|
||||
request: NovelCheckConsistencyRequest,
|
||||
): Promise<NovelConsistencyCheck> {
|
||||
return invoke("novel_check_consistency", { request });
|
||||
}
|
||||
|
||||
export async function getNovelProjectSnapshot(
|
||||
projectId: string,
|
||||
): Promise<NovelProjectSnapshot> {
|
||||
return invoke("novel_get_project_snapshot", { projectId });
|
||||
}
|
||||
|
||||
export async function listNovelRuns(
|
||||
request: NovelListRunsRequest,
|
||||
): Promise<NovelGenerationRun[]> {
|
||||
return invoke("novel_list_runs", { request });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
checkNovelConsistency,
|
||||
continueNovelChapter,
|
||||
generateNovelChapter,
|
||||
type NovelChapterRecord,
|
||||
type NovelConsistencyCheck,
|
||||
} from "@/lib/api/novel";
|
||||
|
||||
export interface GenerateNextChapterFlowParams {
|
||||
projectId: string;
|
||||
hasExistingChapters: boolean;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface GenerateNextChapterFlowResult {
|
||||
chapter: NovelChapterRecord;
|
||||
consistency?: NovelConsistencyCheck;
|
||||
consistencyError?: string;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return "未知错误";
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节半自动编排:生成下一章并尝试执行一致性检查。
|
||||
* 一致性检查失败不会回滚章节,交由上层做软告警展示。
|
||||
*/
|
||||
export async function generateNextChapterWithConsistency(
|
||||
params: GenerateNextChapterFlowParams,
|
||||
): Promise<GenerateNextChapterFlowResult> {
|
||||
const generationResult = params.hasExistingChapters
|
||||
? await continueNovelChapter({
|
||||
project_id: params.projectId,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
})
|
||||
: await generateNovelChapter({
|
||||
project_id: params.projectId,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
});
|
||||
|
||||
const chapter = generationResult.chapter;
|
||||
if (!chapter) {
|
||||
throw new Error("章节生成成功但未返回章节数据");
|
||||
}
|
||||
|
||||
try {
|
||||
const consistency = await checkNovelConsistency({
|
||||
project_id: params.projectId,
|
||||
chapter_id: chapter.id,
|
||||
});
|
||||
return {
|
||||
chapter,
|
||||
consistency,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
chapter,
|
||||
consistencyError: getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { NovelProjectSnapshot } from "@/lib/api/novel";
|
||||
import type { NovelSettingsEnvelope } from "@/lib/novel-settings/types";
|
||||
|
||||
export type NovelPipelineStage =
|
||||
| "setup"
|
||||
| "outline"
|
||||
| "characters"
|
||||
| "chapters"
|
||||
| "qa"
|
||||
| "publish";
|
||||
|
||||
export type NovelStageStatus = "locked" | "ready" | "done" | "warning";
|
||||
|
||||
export type NovelPrimaryActionKey =
|
||||
| "save-settings"
|
||||
| "generate-outline"
|
||||
| "generate-characters"
|
||||
| "generate-next-chapter"
|
||||
| "run-consistency"
|
||||
| "open-publish";
|
||||
|
||||
export interface NovelPipelinePrimaryAction {
|
||||
key: NovelPrimaryActionKey;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface NovelPipelineState {
|
||||
currentStage: NovelPipelineStage;
|
||||
stageStatus: Record<NovelPipelineStage, NovelStageStatus>;
|
||||
primaryAction: NovelPipelinePrimaryAction;
|
||||
}
|
||||
|
||||
function isSetupReady(settings: NovelSettingsEnvelope | null): boolean {
|
||||
if (!settings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { data } = settings;
|
||||
return (
|
||||
data.genres.length > 0 &&
|
||||
data.oneLinePitch.trim().length > 0 &&
|
||||
data.mainCharacter.name.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentStage(
|
||||
stageStatus: Record<NovelPipelineStage, NovelStageStatus>,
|
||||
): NovelPipelineStage {
|
||||
const order: NovelPipelineStage[] = [
|
||||
"setup",
|
||||
"outline",
|
||||
"characters",
|
||||
"chapters",
|
||||
"qa",
|
||||
"publish",
|
||||
];
|
||||
|
||||
for (const stage of order) {
|
||||
if (stageStatus[stage] === "ready" || stageStatus[stage] === "warning") {
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
|
||||
return "publish";
|
||||
}
|
||||
|
||||
function getPrimaryAction(
|
||||
stage: NovelPipelineStage,
|
||||
stageStatus: Record<NovelPipelineStage, NovelStageStatus>,
|
||||
): NovelPipelinePrimaryAction {
|
||||
switch (stage) {
|
||||
case "setup":
|
||||
return {
|
||||
key: "save-settings",
|
||||
label: "保存创作设定",
|
||||
disabled: stageStatus.setup === "locked",
|
||||
};
|
||||
case "outline":
|
||||
return {
|
||||
key: "generate-outline",
|
||||
label: stageStatus.outline === "done" ? "重新生成大纲" : "生成大纲",
|
||||
disabled: stageStatus.outline === "locked",
|
||||
};
|
||||
case "characters":
|
||||
return {
|
||||
key: "generate-characters",
|
||||
label:
|
||||
stageStatus.characters === "done" ? "更新角色阵列" : "生成角色阵列",
|
||||
disabled: stageStatus.characters === "locked",
|
||||
};
|
||||
case "chapters":
|
||||
return {
|
||||
key: "generate-next-chapter",
|
||||
label: "生成下一章(含自动检查)",
|
||||
disabled: stageStatus.chapters === "locked",
|
||||
};
|
||||
case "qa":
|
||||
return {
|
||||
key: "run-consistency",
|
||||
label: "执行一致性检查",
|
||||
disabled: stageStatus.qa === "locked",
|
||||
};
|
||||
case "publish":
|
||||
return {
|
||||
key: "open-publish",
|
||||
label: "前往发布页",
|
||||
disabled: stageStatus.publish === "locked",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
key: "save-settings",
|
||||
label: "保存创作设定",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveNovelPipelineState(params: {
|
||||
snapshot: NovelProjectSnapshot | null;
|
||||
settings: NovelSettingsEnvelope | null;
|
||||
}): NovelPipelineState {
|
||||
const { snapshot, settings } = params;
|
||||
|
||||
const setupDone = isSetupReady(settings);
|
||||
const hasOutline = Boolean(snapshot?.latest_outline);
|
||||
const hasCharacters = (snapshot?.characters.length ?? 0) > 0;
|
||||
const hasChapters = (snapshot?.chapters.length ?? 0) > 0;
|
||||
|
||||
const latestConsistency = snapshot?.latest_consistency;
|
||||
const consistencyScore = latestConsistency?.score;
|
||||
|
||||
const stageStatus: Record<NovelPipelineStage, NovelStageStatus> = {
|
||||
setup: setupDone ? "done" : "ready",
|
||||
outline: setupDone ? (hasOutline ? "done" : "ready") : "locked",
|
||||
characters:
|
||||
setupDone && hasOutline
|
||||
? hasCharacters
|
||||
? "done"
|
||||
: "ready"
|
||||
: "locked",
|
||||
chapters:
|
||||
setupDone && hasOutline && hasCharacters
|
||||
? hasChapters
|
||||
? "done"
|
||||
: "ready"
|
||||
: "locked",
|
||||
qa:
|
||||
setupDone && hasOutline && hasCharacters && hasChapters
|
||||
? consistencyScore === undefined
|
||||
? "ready"
|
||||
: consistencyScore < 60
|
||||
? "warning"
|
||||
: "done"
|
||||
: "locked",
|
||||
publish:
|
||||
setupDone && hasOutline && hasCharacters && hasChapters ? "ready" : "locked",
|
||||
};
|
||||
|
||||
const currentStage = getCurrentStage(stageStatus);
|
||||
|
||||
return {
|
||||
currentStage,
|
||||
stageStatus,
|
||||
primaryAction: getPrimaryAction(currentStage, stageStatus),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 小说创作设定强类型定义(V1)
|
||||
*
|
||||
* 参考: /Users/coso/Documents/dev/js/ainovel/src/components/novel-settings/types.ts
|
||||
*/
|
||||
|
||||
export const NOVEL_SETTINGS_SCHEMA_VERSION = 1;
|
||||
|
||||
const randomId = () => Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
|
||||
export const ALL_NOVEL_GENRES = [
|
||||
"玄幻",
|
||||
"仙侠",
|
||||
"奇幻",
|
||||
"武侠",
|
||||
"都市",
|
||||
"言情",
|
||||
"科幻",
|
||||
"系统文",
|
||||
"后宫",
|
||||
"无限流",
|
||||
"悬疑",
|
||||
"历史",
|
||||
"军事",
|
||||
"游戏",
|
||||
"末世",
|
||||
"穿越",
|
||||
"重生",
|
||||
"网游",
|
||||
"竞技",
|
||||
"机甲",
|
||||
"星际",
|
||||
"灵异",
|
||||
"恐怖",
|
||||
"宫斗",
|
||||
"种田",
|
||||
"娱乐圈",
|
||||
"盗墓",
|
||||
"洪荒",
|
||||
] as const;
|
||||
|
||||
export type NovelGenre = (typeof ALL_NOVEL_GENRES)[number];
|
||||
|
||||
export interface MainCharacter {
|
||||
name: string;
|
||||
gender: string;
|
||||
age: string;
|
||||
personality: string;
|
||||
}
|
||||
|
||||
export interface SideCharacter {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname: string;
|
||||
gender: string;
|
||||
age: string;
|
||||
relationship: string;
|
||||
relationshipCustom: string;
|
||||
personalityTags: string[];
|
||||
background: string;
|
||||
abilities: string;
|
||||
role: string;
|
||||
arc: string;
|
||||
arcCustom: string;
|
||||
}
|
||||
|
||||
export interface Antagonist extends SideCharacter {
|
||||
motive: string;
|
||||
fate: string;
|
||||
}
|
||||
|
||||
export interface WorldDetails {
|
||||
powerSystem: string;
|
||||
factions: string;
|
||||
historyEvents: string;
|
||||
importantLocations: string;
|
||||
cultureAndTaboos: string;
|
||||
}
|
||||
|
||||
export interface PlotBeat {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface WritingStyle {
|
||||
narration: string;
|
||||
tones: string[];
|
||||
cheatLevel: string;
|
||||
focusAreas: string[];
|
||||
wordsPerChapter: number;
|
||||
temperature: number;
|
||||
}
|
||||
|
||||
export interface TabooRule {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ReferenceWork {
|
||||
id: string;
|
||||
title: string;
|
||||
inspiration: string;
|
||||
}
|
||||
|
||||
export interface NovelSettingsV1 {
|
||||
genres: NovelGenre[];
|
||||
oneLinePitch: string;
|
||||
mainCharacter: MainCharacter;
|
||||
sideCharacters: SideCharacter[];
|
||||
antagonists: Antagonist[];
|
||||
worldSummary: string;
|
||||
conflictTheme: string;
|
||||
worldDetails: WorldDetails;
|
||||
opening: string;
|
||||
middleBeats: PlotBeat[];
|
||||
endingType: string;
|
||||
subplots: PlotBeat[];
|
||||
writingStyle: WritingStyle;
|
||||
totalWords: number;
|
||||
chapterWords: number;
|
||||
nsfw: boolean;
|
||||
systemNovel: boolean;
|
||||
harem: boolean;
|
||||
taboos: TabooRule[];
|
||||
references: ReferenceWork[];
|
||||
}
|
||||
|
||||
export interface NovelSettingsEnvelope {
|
||||
schema_version: number;
|
||||
data: NovelSettingsV1;
|
||||
}
|
||||
|
||||
export const createEmptySideCharacter = (): SideCharacter => ({
|
||||
id: randomId(),
|
||||
name: "",
|
||||
nickname: "",
|
||||
gender: "男",
|
||||
age: "",
|
||||
relationship: "",
|
||||
relationshipCustom: "",
|
||||
personalityTags: [],
|
||||
background: "",
|
||||
abilities: "",
|
||||
role: "",
|
||||
arc: "",
|
||||
arcCustom: "",
|
||||
});
|
||||
|
||||
export const createEmptyAntagonist = (): Antagonist => ({
|
||||
...createEmptySideCharacter(),
|
||||
motive: "",
|
||||
fate: "",
|
||||
});
|
||||
|
||||
export const createEmptyPlotBeat = (): PlotBeat => ({
|
||||
id: randomId(),
|
||||
title: "",
|
||||
detail: "",
|
||||
});
|
||||
|
||||
export const createEmptyTaboo = (): TabooRule => ({
|
||||
id: randomId(),
|
||||
content: "",
|
||||
});
|
||||
|
||||
export const createEmptyReferenceWork = (): ReferenceWork => ({
|
||||
id: randomId(),
|
||||
title: "",
|
||||
inspiration: "",
|
||||
});
|
||||
|
||||
export const createDefaultNovelSettingsV1 = (): NovelSettingsV1 => ({
|
||||
genres: [],
|
||||
oneLinePitch: "",
|
||||
mainCharacter: {
|
||||
name: "",
|
||||
gender: "男",
|
||||
age: "",
|
||||
personality: "",
|
||||
},
|
||||
sideCharacters: [],
|
||||
antagonists: [],
|
||||
worldSummary: "",
|
||||
conflictTheme: "",
|
||||
worldDetails: {
|
||||
powerSystem: "",
|
||||
factions: "",
|
||||
historyEvents: "",
|
||||
importantLocations: "",
|
||||
cultureAndTaboos: "",
|
||||
},
|
||||
opening: "",
|
||||
middleBeats: [],
|
||||
endingType: "",
|
||||
subplots: [],
|
||||
writingStyle: {
|
||||
narration: "第三人称有限",
|
||||
tones: [],
|
||||
cheatLevel: "稳步成长",
|
||||
focusAreas: [],
|
||||
wordsPerChapter: 3000,
|
||||
temperature: 0.7,
|
||||
},
|
||||
totalWords: 100000,
|
||||
chapterWords: 3000,
|
||||
nsfw: false,
|
||||
systemNovel: false,
|
||||
harem: false,
|
||||
taboos: [],
|
||||
references: [],
|
||||
});
|
||||
|
||||
export const createDefaultNovelSettingsEnvelope = (): NovelSettingsEnvelope => ({
|
||||
schema_version: NOVEL_SETTINGS_SCHEMA_VERSION,
|
||||
data: createDefaultNovelSettingsV1(),
|
||||
});
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown, fallback = false): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
function normalizeMainCharacter(value: unknown): MainCharacter {
|
||||
const base = createDefaultNovelSettingsV1().mainCharacter;
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
name: asString(value.name, base.name),
|
||||
gender: asString(value.gender, base.gender),
|
||||
age: asString(value.age, base.age),
|
||||
personality: asString(value.personality, base.personality),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSideCharacter(value: unknown): SideCharacter {
|
||||
const base = createEmptySideCharacter();
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
id: asString(value.id, base.id),
|
||||
name: asString(value.name, base.name),
|
||||
nickname: asString(value.nickname, base.nickname),
|
||||
gender: asString(value.gender, base.gender),
|
||||
age: asString(value.age, base.age),
|
||||
relationship: asString(value.relationship, base.relationship),
|
||||
relationshipCustom: asString(value.relationshipCustom, base.relationshipCustom),
|
||||
personalityTags: asStringArray(value.personalityTags),
|
||||
background: asString(value.background, base.background),
|
||||
abilities: asString(value.abilities, base.abilities),
|
||||
role: asString(value.role, base.role),
|
||||
arc: asString(value.arc, base.arc),
|
||||
arcCustom: asString(value.arcCustom, base.arcCustom),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAntagonist(value: unknown): Antagonist {
|
||||
const base = createEmptyAntagonist();
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
const side = normalizeSideCharacter(value);
|
||||
return {
|
||||
...side,
|
||||
motive: asString(value.motive, base.motive),
|
||||
fate: asString(value.fate, base.fate),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorldDetails(value: unknown): WorldDetails {
|
||||
const base = createDefaultNovelSettingsV1().worldDetails;
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
powerSystem: asString(value.powerSystem, base.powerSystem),
|
||||
factions: asString(value.factions, base.factions),
|
||||
historyEvents: asString(value.historyEvents, base.historyEvents),
|
||||
importantLocations: asString(value.importantLocations, base.importantLocations),
|
||||
cultureAndTaboos: asString(value.cultureAndTaboos, base.cultureAndTaboos),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlotBeat(value: unknown): PlotBeat {
|
||||
const base = createEmptyPlotBeat();
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
id: asString(value.id, base.id),
|
||||
title: asString(value.title, base.title),
|
||||
detail: asString(value.detail, base.detail),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWritingStyle(value: unknown): WritingStyle {
|
||||
const base = createDefaultNovelSettingsV1().writingStyle;
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
narration: asString(value.narration, base.narration),
|
||||
tones: asStringArray(value.tones),
|
||||
cheatLevel: asString(value.cheatLevel, base.cheatLevel),
|
||||
focusAreas: asStringArray(value.focusAreas),
|
||||
wordsPerChapter: asNumber(value.wordsPerChapter, base.wordsPerChapter),
|
||||
temperature: asNumber(value.temperature, base.temperature),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaboo(value: unknown): TabooRule {
|
||||
const base = createEmptyTaboo();
|
||||
if (typeof value === "string") {
|
||||
return { ...base, content: value };
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
id: asString(value.id, base.id),
|
||||
content: asString(value.content, base.content),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReference(value: unknown): ReferenceWork {
|
||||
const base = createEmptyReferenceWork();
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
id: asString(value.id, base.id),
|
||||
title: asString(value.title, base.title),
|
||||
inspiration: asString(value.inspiration, base.inspiration),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNovelSettings(value: unknown): NovelSettingsV1 {
|
||||
const base = createDefaultNovelSettingsV1();
|
||||
if (!isRecord(value)) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const genres = Array.isArray(value.genres)
|
||||
? value.genres.filter((item): item is NovelGenre => typeof item === "string")
|
||||
: base.genres;
|
||||
|
||||
return {
|
||||
genres,
|
||||
oneLinePitch: asString(value.oneLinePitch, base.oneLinePitch),
|
||||
mainCharacter: normalizeMainCharacter(value.mainCharacter),
|
||||
sideCharacters: Array.isArray(value.sideCharacters)
|
||||
? value.sideCharacters.map(normalizeSideCharacter)
|
||||
: base.sideCharacters,
|
||||
antagonists: Array.isArray(value.antagonists)
|
||||
? value.antagonists.map(normalizeAntagonist)
|
||||
: base.antagonists,
|
||||
worldSummary: asString(value.worldSummary, base.worldSummary),
|
||||
conflictTheme: asString(value.conflictTheme, base.conflictTheme),
|
||||
worldDetails: normalizeWorldDetails(value.worldDetails),
|
||||
opening: asString(value.opening, base.opening),
|
||||
middleBeats: Array.isArray(value.middleBeats)
|
||||
? value.middleBeats.map(normalizePlotBeat)
|
||||
: base.middleBeats,
|
||||
endingType: asString(value.endingType, base.endingType),
|
||||
subplots: Array.isArray(value.subplots)
|
||||
? value.subplots.map(normalizePlotBeat)
|
||||
: base.subplots,
|
||||
writingStyle: normalizeWritingStyle(value.writingStyle),
|
||||
totalWords: asNumber(value.totalWords, base.totalWords),
|
||||
chapterWords: asNumber(value.chapterWords, base.chapterWords),
|
||||
nsfw: asBoolean(value.nsfw, base.nsfw),
|
||||
systemNovel: asBoolean(value.systemNovel, base.systemNovel),
|
||||
harem: asBoolean(value.harem, base.harem),
|
||||
taboos: Array.isArray(value.taboos)
|
||||
? value.taboos.map(normalizeTaboo).filter((item) => item.content.trim())
|
||||
: base.taboos,
|
||||
references: Array.isArray(value.references)
|
||||
? value.references
|
||||
.map(normalizeReference)
|
||||
.filter((item) => item.title.trim() || item.inspiration.trim())
|
||||
: base.references,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNovelSettingsEnvelope(value: unknown): NovelSettingsEnvelope {
|
||||
if (isRecord(value) && isRecord(value.data)) {
|
||||
const schemaVersion = asNumber(value.schema_version, NOVEL_SETTINGS_SCHEMA_VERSION);
|
||||
return {
|
||||
schema_version: schemaVersion,
|
||||
data: normalizeNovelSettings(value.data),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: NOVEL_SETTINGS_SCHEMA_VERSION,
|
||||
data: normalizeNovelSettings(value),
|
||||
};
|
||||
}
|
||||
@@ -80,6 +80,9 @@ const defaultMocks: Record<string, any> = {
|
||||
shortcut: "",
|
||||
},
|
||||
},
|
||||
web_search: {
|
||||
engine: "google",
|
||||
},
|
||||
}),
|
||||
|
||||
save_config: (config: any) => {
|
||||
@@ -122,6 +125,126 @@ const defaultMocks: Record<string, any> = {
|
||||
lan_ip: "192.168.1.100",
|
||||
all_ips: ["127.0.0.1", "192.168.1.100"],
|
||||
}),
|
||||
get_chrome_profile_sessions: () => [],
|
||||
close_chrome_profile_session: () => true,
|
||||
open_chrome_profile_window: () => ({
|
||||
success: true,
|
||||
reused: false,
|
||||
browser_source: "system",
|
||||
browser_path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
profile_dir: "/tmp/proxycast/chrome_profiles/search_google",
|
||||
remote_debugging_port: 13001,
|
||||
pid: 12345,
|
||||
devtools_http_url: "http://127.0.0.1:13001/json/version",
|
||||
}),
|
||||
get_chrome_bridge_endpoint_info: () => ({
|
||||
server_running: true,
|
||||
host: "127.0.0.1",
|
||||
port: 8999,
|
||||
observer_ws_url:
|
||||
"ws://127.0.0.1:8999/proxycast-chrome-observer/Proxycast_Key=proxy_cast",
|
||||
control_ws_url:
|
||||
"ws://127.0.0.1:8999/proxycast-chrome-control/Proxycast_Key=proxy_cast",
|
||||
bridge_key: "proxy_cast",
|
||||
}),
|
||||
get_chrome_bridge_status: () => ({
|
||||
observer_count: 0,
|
||||
control_count: 0,
|
||||
pending_command_count: 0,
|
||||
observers: [],
|
||||
controls: [],
|
||||
pending_commands: [],
|
||||
}),
|
||||
chrome_bridge_execute_command: (args: any) => ({
|
||||
success: true,
|
||||
request_id: `mock-${Date.now()}`,
|
||||
command: args?.request?.command ?? "get_page_info",
|
||||
message: "mock command result",
|
||||
page_info: {
|
||||
title: "Mock Page",
|
||||
url: "https://example.com",
|
||||
markdown: "# Mock Page\nURL: https://example.com",
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
get_browser_backend_policy: () => ({
|
||||
priority: ["aster_compat", "proxycast_extension_bridge", "cdp_direct"],
|
||||
auto_fallback: true,
|
||||
}),
|
||||
set_browser_backend_policy: (args: any) => ({
|
||||
priority:
|
||||
args?.policy?.priority ?? [
|
||||
"aster_compat",
|
||||
"proxycast_extension_bridge",
|
||||
"cdp_direct",
|
||||
],
|
||||
auto_fallback: args?.policy?.auto_fallback ?? true,
|
||||
}),
|
||||
get_browser_backends_status: () => ({
|
||||
policy: {
|
||||
priority: ["aster_compat", "proxycast_extension_bridge", "cdp_direct"],
|
||||
auto_fallback: true,
|
||||
},
|
||||
bridge_observer_count: 1,
|
||||
bridge_control_count: 0,
|
||||
running_profile_count: 1,
|
||||
cdp_alive_profile_count: 1,
|
||||
aster_native_host_supported: true,
|
||||
aster_native_host_configured: false,
|
||||
backends: [
|
||||
{
|
||||
backend: "aster_compat",
|
||||
available: true,
|
||||
capabilities: ["navigate", "read_page", "tabs_context_mcp"],
|
||||
},
|
||||
{
|
||||
backend: "proxycast_extension_bridge",
|
||||
available: true,
|
||||
capabilities: ["open_url", "click", "type", "get_page_info"],
|
||||
},
|
||||
{
|
||||
backend: "cdp_direct",
|
||||
available: true,
|
||||
capabilities: ["tabs_context_mcp", "navigate", "read_page"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
browser_execute_action: (args: any) => ({
|
||||
success: true,
|
||||
backend: args?.request?.backend ?? "aster_compat",
|
||||
action: args?.request?.action ?? "navigate",
|
||||
request_id: `browser-mock-${Date.now()}`,
|
||||
data: {
|
||||
message: "mock browser action executed",
|
||||
},
|
||||
attempts: [
|
||||
{
|
||||
backend: args?.request?.backend ?? "aster_compat",
|
||||
success: true,
|
||||
message: "执行成功",
|
||||
},
|
||||
],
|
||||
}),
|
||||
get_browser_action_audit_logs: (args: any) => {
|
||||
const now = new Date().toISOString();
|
||||
const count = Math.min(Number(args?.limit ?? 20), 200);
|
||||
return Array.from({ length: Math.max(1, count) }, (_, idx) => ({
|
||||
id: `audit-mock-${idx + 1}`,
|
||||
created_at: now,
|
||||
action: "navigate",
|
||||
profile_key: "default",
|
||||
requested_backend: "aster_compat",
|
||||
selected_backend: "aster_compat",
|
||||
success: true,
|
||||
attempts: [
|
||||
{
|
||||
backend: "aster_compat",
|
||||
success: true,
|
||||
message: "执行成功",
|
||||
},
|
||||
],
|
||||
}));
|
||||
},
|
||||
|
||||
// Agent 相关
|
||||
list_agent_sessions: () => [],
|
||||
|
||||
@@ -48,6 +48,10 @@ export interface CreateWebviewRequest {
|
||||
width: number;
|
||||
/** 高度 */
|
||||
height: number;
|
||||
/** Profile 隔离键(可选) */
|
||||
profile_key?: string;
|
||||
/** 是否启用持久化 profile(可选) */
|
||||
persistent_profile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,6 +66,187 @@ export interface CreateWebviewResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动外部 Chrome Profile 的请求参数
|
||||
*/
|
||||
export interface OpenChromeProfileRequest {
|
||||
/** Profile 隔离键 */
|
||||
profile_key: string;
|
||||
/** 要打开的 URL */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动外部 Chrome Profile 的响应
|
||||
*/
|
||||
export interface OpenChromeProfileResponse {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 是否复用已存在会话 */
|
||||
reused?: boolean;
|
||||
/** 浏览器来源 */
|
||||
browser_source?: "system" | "playwright";
|
||||
/** 浏览器可执行文件路径 */
|
||||
browser_path?: string;
|
||||
/** Profile 目录 */
|
||||
profile_dir?: string;
|
||||
/** 远程调试端口 */
|
||||
remote_debugging_port?: number;
|
||||
/** Chrome 进程 PID */
|
||||
pid?: number;
|
||||
/** DevTools HTTP URL */
|
||||
devtools_http_url?: string;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome Profile 会话信息
|
||||
*/
|
||||
export interface ChromeProfileSessionInfo {
|
||||
profile_key: string;
|
||||
browser_source: "system" | "playwright";
|
||||
browser_path: string;
|
||||
profile_dir: string;
|
||||
remote_debugging_port: number;
|
||||
pid: number;
|
||||
started_at: string;
|
||||
last_url: string;
|
||||
}
|
||||
|
||||
export interface ChromeBridgeEndpointInfo {
|
||||
server_running: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
observer_ws_url: string;
|
||||
control_ws_url: string;
|
||||
bridge_key: string;
|
||||
}
|
||||
|
||||
export interface ChromeBridgePageInfo {
|
||||
title?: string;
|
||||
url?: string;
|
||||
markdown: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChromeBridgeObserverSnapshot {
|
||||
client_id: string;
|
||||
profile_key: string;
|
||||
connected_at: string;
|
||||
user_agent?: string;
|
||||
last_heartbeat_at?: string;
|
||||
last_page_info?: ChromeBridgePageInfo;
|
||||
}
|
||||
|
||||
export interface ChromeBridgeControlSnapshot {
|
||||
client_id: string;
|
||||
connected_at: string;
|
||||
user_agent?: string;
|
||||
}
|
||||
|
||||
export interface ChromeBridgePendingCommandSnapshot {
|
||||
request_id: string;
|
||||
source_type: "api" | "control";
|
||||
command: string;
|
||||
observer_client_id: string;
|
||||
wait_for_page_info: boolean;
|
||||
command_completed: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChromeBridgeStatusSnapshot {
|
||||
observer_count: number;
|
||||
control_count: number;
|
||||
pending_command_count: number;
|
||||
observers: ChromeBridgeObserverSnapshot[];
|
||||
controls: ChromeBridgeControlSnapshot[];
|
||||
pending_commands: ChromeBridgePendingCommandSnapshot[];
|
||||
}
|
||||
|
||||
export interface ChromeBridgeCommandRequest {
|
||||
profile_key?: string;
|
||||
command: string;
|
||||
target?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
wait_for_page_info?: boolean;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface ChromeBridgeCommandResult {
|
||||
success: boolean;
|
||||
request_id: string;
|
||||
command: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
page_info?: ChromeBridgePageInfo;
|
||||
}
|
||||
|
||||
export type BrowserBackendType =
|
||||
| "aster_compat"
|
||||
| "proxycast_extension_bridge"
|
||||
| "cdp_direct";
|
||||
|
||||
export interface BrowserBackendPolicy {
|
||||
priority: BrowserBackendType[];
|
||||
auto_fallback: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserBackendStatusItem {
|
||||
backend: BrowserBackendType;
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export interface BrowserBackendsStatusSnapshot {
|
||||
policy: BrowserBackendPolicy;
|
||||
bridge_observer_count: number;
|
||||
bridge_control_count: number;
|
||||
running_profile_count: number;
|
||||
cdp_alive_profile_count: number;
|
||||
aster_native_host_supported: boolean;
|
||||
aster_native_host_configured: boolean;
|
||||
backends: BrowserBackendStatusItem[];
|
||||
}
|
||||
|
||||
export interface BrowserActionRequest {
|
||||
profile_key?: string;
|
||||
backend?: BrowserBackendType;
|
||||
action: string;
|
||||
args?: Record<string, unknown>;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface BrowserActionAttempt {
|
||||
backend: BrowserBackendType;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BrowserActionResult {
|
||||
success: boolean;
|
||||
backend?: BrowserBackendType;
|
||||
action: string;
|
||||
request_id: string;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
attempts: BrowserActionAttempt[];
|
||||
}
|
||||
|
||||
export interface BrowserActionAuditRecord {
|
||||
id: string;
|
||||
created_at: string;
|
||||
action: string;
|
||||
profile_key?: string;
|
||||
requested_backend?: BrowserBackendType;
|
||||
selected_backend?: BrowserBackendType;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
attempts: BrowserActionAttempt[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新的 webview 窗口来显示外部 URL
|
||||
*
|
||||
@@ -74,6 +259,90 @@ export async function createWebviewPanel(
|
||||
return safeInvoke<CreateWebviewResponse>("create_webview_panel", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用外部 Chrome + 独立 Profile 打开 URL
|
||||
*/
|
||||
export async function openChromeProfileWindow(
|
||||
request: OpenChromeProfileRequest,
|
||||
): Promise<OpenChromeProfileResponse> {
|
||||
return safeInvoke<OpenChromeProfileResponse>("open_chrome_profile_window", {
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前运行中的 Chrome Profile 会话
|
||||
*/
|
||||
export async function getChromeProfileSessions(): Promise<ChromeProfileSessionInfo[]> {
|
||||
return safeInvoke<ChromeProfileSessionInfo[]>("get_chrome_profile_sessions");
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定的 Chrome Profile 会话
|
||||
*/
|
||||
export async function closeChromeProfileSession(
|
||||
profileKey: string,
|
||||
): Promise<boolean> {
|
||||
return safeInvoke<boolean>("close_chrome_profile_session", {
|
||||
profile_key: profileKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ChromeBridge 端点信息(用于扩展配置)
|
||||
*/
|
||||
export async function getChromeBridgeEndpointInfo(): Promise<ChromeBridgeEndpointInfo> {
|
||||
return safeInvoke<ChromeBridgeEndpointInfo>("get_chrome_bridge_endpoint_info");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ChromeBridge 当前连接状态
|
||||
*/
|
||||
export async function getChromeBridgeStatus(): Promise<ChromeBridgeStatusSnapshot> {
|
||||
return safeInvoke<ChromeBridgeStatusSnapshot>("get_chrome_bridge_status");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ChromeBridge 发送测试命令
|
||||
*/
|
||||
export async function chromeBridgeExecuteCommand(
|
||||
request: ChromeBridgeCommandRequest,
|
||||
): Promise<ChromeBridgeCommandResult> {
|
||||
return safeInvoke<ChromeBridgeCommandResult>("chrome_bridge_execute_command", {
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBrowserBackendPolicy(): Promise<BrowserBackendPolicy> {
|
||||
return safeInvoke<BrowserBackendPolicy>("get_browser_backend_policy");
|
||||
}
|
||||
|
||||
export async function setBrowserBackendPolicy(
|
||||
policy: BrowserBackendPolicy,
|
||||
): Promise<BrowserBackendPolicy> {
|
||||
return safeInvoke<BrowserBackendPolicy>("set_browser_backend_policy", {
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBrowserBackendsStatus(): Promise<BrowserBackendsStatusSnapshot> {
|
||||
return safeInvoke<BrowserBackendsStatusSnapshot>("get_browser_backends_status");
|
||||
}
|
||||
|
||||
export async function browserExecuteAction(
|
||||
request: BrowserActionRequest,
|
||||
): Promise<BrowserActionResult> {
|
||||
return safeInvoke<BrowserActionResult>("browser_execute_action", { request });
|
||||
}
|
||||
|
||||
export async function getBrowserActionAuditLogs(
|
||||
limit?: number,
|
||||
): Promise<BrowserActionAuditRecord[]> {
|
||||
return safeInvoke<BrowserActionAuditRecord[]>("get_browser_action_audit_logs", {
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 webview 面板
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user