feat(channel): add customizable JS model-call scripts with split-panel editor

- Run user-authored async scripts per model with flat locals (prompt,
  images, messages, params, model, baseUrl, apiKey, http, request, poll,
  sleep, signal, onDelta); empty script falls back to system default.
- Wire image/video/audio/text request entrypoints through the plugin
  runtime, keeping default handlers unchanged.
- Split-panel script editor: variable/return docs on the left, CodeMirror
  JS editor on the right; title shows capability - model.
- Provide OpenAI and Gemini template buttons per capability; text template
  uses the /responses API; image template branches text2img vs img2img.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
HouYunFei
2026-07-15 11:24:34 +08:00
parent 3963c6eebf
commit f0db29d8e3
6 changed files with 341 additions and 132 deletions
@@ -1,18 +1,16 @@
import { Button, Input, Modal } from "antd";
import { javascript } from "@codemirror/lang-javascript";
import CodeMirror from "@uiw/react-codemirror";
import { Button, Modal } from "antd";
import { useEffect, useState } from "react";
import { PLUGIN_TEMPLATES } from "@/services/api/model-plugin";
import { PLUGIN_RETURNS, PLUGIN_TEMPLATES, PLUGIN_VARIABLES } from "@/services/api/model-plugin";
import type { ModelCapability } from "@/stores/use-config-store";
const capabilityLabels: Record<ModelCapability, string> = { image: "生图", video: "视频", text: "文本", audio: "音频" };
const variableHints = [
"input:本次请求的归一化输入(prompt / references / messages / params / body",
"config{ baseUrl, apiKey, model, apiFormat, systemPrompt }",
"http{ url(path), post(path, body, opts), get(path, opts) },已绑定当前渠道",
"poll(request, extract, { intervalMs, timeoutMs }):轮询直到 extract 返回真值",
"sleep(ms)、signal:延时与取消信号;onDelta(text):推送流式文本(文本模型)",
];
function isDarkMode() {
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
}
export function ModelScriptEditor({ open, capability, modelName, value, onSave, onClose }: { open: boolean; capability: ModelCapability; modelName: string; value: string; onSave: (script: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(value);
@@ -20,48 +18,95 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
if (open) setDraft(value);
}, [open, value]);
const variables = PLUGIN_VARIABLES.filter((variable) => !variable.capabilities || variable.capabilities.includes(capability));
return (
<Modal
open={open}
title={
<div>
<div className="text-base font-semibold"> · {modelName}</div>
<div className="mt-1 text-xs font-normal text-stone-500">{capabilityLabels[capability]}使</div>
<div className="text-base font-semibold">
{capabilityLabels[capability]}
{modelName ? ` - ${modelName}` : ""}
</div>
<div className="mt-1 text-xs font-normal text-stone-500">使 return 使</div>
</div>
}
width={720}
width={1080}
centered
onCancel={onClose}
styles={{ body: { maxHeight: "64vh", overflowY: "auto" } }}
footer={[
<Button key="template" onClick={() => setDraft(PLUGIN_TEMPLATES[capability])}>
</Button>,
<Button key="reset" danger onClick={() => setDraft("")}>
</Button>,
<Button key="cancel" onClick={onClose}>
</Button>,
<Button
key="save"
type="primary"
onClick={() => {
onSave(draft.trim());
onClose();
}}
>
</Button>,
]}
styles={{ body: { padding: 0 } }}
footer={
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{PLUGIN_TEMPLATES[capability].map((template) => (
<Button key={template.label} size="small" onClick={() => setDraft(template.script)}>
{template.label}
</Button>
))}
<Button size="small" danger onClick={() => setDraft("")}>
</Button>
</div>
<div className="flex items-center gap-2">
<Button onClick={onClose}></Button>
<Button
type="primary"
onClick={() => {
onSave(draft.trim());
onClose();
}}
>
</Button>
</div>
</div>
}
>
<div className="mb-3 rounded-md border border-stone-200 bg-stone-50 p-3 text-xs leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900/40 dark:text-stone-300">
<div className="mb-1 font-semibold"> return </div>
{variableHints.map((hint) => (
<div key={hint}>· {hint}</div>
))}
<div className="flex h-[60vh] min-h-[420px] border-t border-stone-200 dark:border-stone-800">
<aside className="flex w-[320px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
<div className="border-b border-stone-200/70 px-4 py-3 dark:border-stone-800/70">
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400"></div>
<div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{PLUGIN_RETURNS[capability]}</div>
</div>
<div className="px-4 py-3">
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400"></span>
<span className="text-[10px] text-stone-400"></span>
</div>
<div className="space-y-1.5">
{variables.map((variable) => (
<button
key={variable.name}
type="button"
onClick={() => setDraft((current) => (current ? `${current}\n${variable.name}` : variable.name))}
className="group block w-full rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors hover:border-stone-200 hover:bg-white dark:hover:border-stone-700 dark:hover:bg-stone-800/60"
>
<div className="flex flex-wrap items-baseline gap-1.5">
<code className="rounded bg-stone-200/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-stone-800 group-hover:bg-blue-100 group-hover:text-blue-700 dark:bg-stone-800 dark:text-stone-100 dark:group-hover:bg-blue-950 dark:group-hover:text-blue-300">
{variable.name}
</code>
<span className="font-mono text-[10px] text-stone-400">{variable.type}</span>
</div>
<div className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{variable.desc}</div>
</button>
))}
</div>
</div>
</aside>
<div className="min-w-0 flex-1 overflow-hidden bg-white dark:bg-stone-950">
<CodeMirror
value={draft}
onChange={setDraft}
height="100%"
theme={isDarkMode() ? "dark" : "light"}
extensions={[javascript()]}
placeholder={"// 留空使用系统默认调用;点击右下角「插入模板」查看示例。"}
style={{ height: "100%", fontSize: 13 }}
className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto"
/>
</div>
</div>
<Input.TextArea value={draft} onChange={(event) => setDraft(event.target.value)} autoSize={{ minRows: 12, maxRows: 24 }} spellCheck={false} placeholder="留空使用系统默认调用;点击“插入模板”查看示例。" style={{ fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)", fontSize: 12 }} />
</Modal>
);
}