Add Agent capabilities and configurations across the application

- Extend the AssistantConfig model to include agent_interface_type, agent_values, and agent_secrets for enhanced agent management.
- Update ModelType to include "Agent" for broader capability support.
- Seed new agent models and configurations in the database for Dify, FastGPT, and OpenCode applications.
- Modify the Assistant routes to validate and handle agent capabilities.
- Implement agent resource resolution in the config resolver for runtime configuration.
- Enhance the interface catalog with agent-specific fields and definitions.
- Update frontend components to manage agent configurations, including form handling and state management for agent-related inputs.
This commit is contained in:
Xin Wang
2026-07-09 15:29:25 +08:00
parent 03478fa557
commit 54329aa516
11 changed files with 238 additions and 114 deletions

View File

@@ -131,9 +131,7 @@ type AssistantForm = {
type FastGptForm = {
name: string;
appId: string;
apiUrl: string;
apiKey: string;
agent: string;
asr: string;
voice: string;
enableInterrupt: boolean;
@@ -141,8 +139,7 @@ type FastGptForm = {
type DifyForm = {
name: string;
apiUrl: string;
apiKey: string;
agent: string;
asr: string;
voice: string;
enableInterrupt: boolean;
@@ -151,8 +148,7 @@ type DifyForm = {
type OpenCodeForm = {
name: string;
prompt: string;
apiUrl: string;
apiKey: string;
agent: string;
model: string;
asr: string;
voice: string;
@@ -226,9 +222,7 @@ function blankPromptForm(name: string): AssistantForm {
function blankFastGptForm(name: string): FastGptForm {
return {
name,
appId: "",
apiUrl: "",
apiKey: "",
agent: "",
asr: "",
voice: "",
enableInterrupt: true,
@@ -238,8 +232,7 @@ function blankFastGptForm(name: string): FastGptForm {
function blankDifyForm(name: string): DifyForm {
return {
name,
apiUrl: "",
apiKey: "",
agent: "",
asr: "",
voice: "",
enableInterrupt: true,
@@ -250,8 +243,7 @@ function blankOpenCodeForm(name: string): OpenCodeForm {
return {
name,
prompt: "",
apiUrl: "",
apiKey: "",
agent: "",
model: "",
asr: "",
voice: "",
@@ -434,6 +426,14 @@ export function AssistantPage(props: AssistantPageProps) {
modelResources
.filter((c) => c.capability === type)
.map((c) => ({ value: c.id, label: c.name }));
const agentOptions = (interfaceType: "dify" | "fastgpt" | "opencode") =>
modelResources
.filter(
(resource) =>
resource.capability === "Agent" &&
resource.interfaceType === interfaceType,
)
.map((resource) => ({ value: resource.id, label: resource.name }));
const visionModelOptionsFor = (currentModelId: string) => modelResources
.filter(
(c) =>
@@ -573,15 +573,15 @@ export function AssistantPage(props: AssistantPageProps) {
if (view === "create") {
setSavedSnapshot(JSON.stringify(form));
} else if (view === "create-dify") {
const next = { ...difyForm, apiKey: "" };
const next = { ...difyForm };
setDifyForm(next);
setSavedSnapshot(JSON.stringify(next));
} else if (view === "create-fastgpt") {
const next = { ...fastGptForm, apiKey: "" };
const next = { ...fastGptForm };
setFastGptForm(next);
setSavedSnapshot(JSON.stringify(next));
} else if (view === "create-opencode") {
const next = { ...openCodeForm, apiKey: "" };
const next = { ...openCodeForm };
setOpenCodeForm(next);
setSavedSnapshot(JSON.stringify(next));
} else if (view === "workflow") {
@@ -626,9 +626,7 @@ export function AssistantPage(props: AssistantPageProps) {
function fillDifyForm(a: Assistant): DifyForm {
const next: DifyForm = {
name: a.name,
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
agent: a.modelResourceIds.Agent ?? "",
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
@@ -644,11 +642,10 @@ export function AssistantPage(props: AssistantPageProps) {
type: "dify",
enableInterrupt: difyForm.enableInterrupt,
modelResourceIds: {
...(difyForm.agent ? { Agent: difyForm.agent } : {}),
...(difyForm.asr ? { ASR: difyForm.asr } : {}),
...(difyForm.voice ? { TTS: difyForm.voice } : {}),
},
apiUrl: difyForm.apiUrl,
apiKey: difyForm.apiKey,
}),
);
}
@@ -657,10 +654,7 @@ export function AssistantPage(props: AssistantPageProps) {
function fillFastGptForm(a: Assistant): FastGptForm {
const next: FastGptForm = {
name: a.name,
appId: a.appId,
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
agent: a.modelResourceIds.Agent ?? "",
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
@@ -676,12 +670,10 @@ export function AssistantPage(props: AssistantPageProps) {
type: "fastgpt",
enableInterrupt: fastGptForm.enableInterrupt,
modelResourceIds: {
...(fastGptForm.agent ? { Agent: fastGptForm.agent } : {}),
...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}),
...(fastGptForm.voice ? { TTS: fastGptForm.voice } : {}),
},
appId: fastGptForm.appId,
apiUrl: fastGptForm.apiUrl,
apiKey: fastGptForm.apiKey,
}),
);
}
@@ -691,9 +683,7 @@ export function AssistantPage(props: AssistantPageProps) {
const next: OpenCodeForm = {
name: a.name,
prompt: a.prompt,
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
agent: a.modelResourceIds.Agent ?? "",
model: a.modelResourceIds.LLM ?? "",
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
@@ -772,13 +762,12 @@ export function AssistantPage(props: AssistantPageProps) {
visionEnabled: openCodeForm.visionEnabled,
visionModelResourceId: openCodeForm.visionModelResourceId || null,
modelResourceIds: {
...(openCodeForm.agent ? { Agent: openCodeForm.agent } : {}),
...(openCodeForm.model ? { LLM: openCodeForm.model } : {}),
...(openCodeForm.asr ? { ASR: openCodeForm.asr } : {}),
...(openCodeForm.voice ? { TTS: openCodeForm.voice } : {}),
},
prompt: openCodeForm.prompt,
apiUrl: openCodeForm.apiUrl,
apiKey: openCodeForm.apiKey,
}),
);
}
@@ -1355,7 +1344,9 @@ export function AssistantPage(props: AssistantPageProps) {
)}
<Button
className="gap-2"
disabled={saving || !dirty || !difyForm.name.trim()}
disabled={
saving || !dirty || !difyForm.name.trim() || !difyForm.agent
}
onClick={() => void handleSaveDify()}
>
{saving ? (
@@ -1373,20 +1364,14 @@ export function AssistantPage(props: AssistantPageProps) {
<SectionCard
icon={<Boxes size={18} />}
title="Dify 应用配置"
description="填写 API URL 与 API Key 以对接 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
>
<InputField
label="API URL"
value={difyForm.apiUrl}
onChange={(value) => updateDifyForm("apiUrl", value)}
placeholder="https://api.dify.ai/v1/chat-messages"
/>
<SecretInputField
label="API Key"
value={difyForm.apiKey}
onChange={(value) => updateDifyForm("apiKey", value)}
placeholder="请输入 Dify API Key"
storedValueMask={storedApiKeyMask}
<ResourceSelectField
label="Dify 应用"
value={difyForm.agent}
onChange={(value) => updateDifyForm("agent", value)}
options={agentOptions("dify")}
noneLabel="请选择"
/>
</SectionCard>
@@ -1454,7 +1439,12 @@ export function AssistantPage(props: AssistantPageProps) {
)}
<Button
className="gap-2"
disabled={saving || !dirty || !fastGptForm.name.trim()}
disabled={
saving ||
!dirty ||
!fastGptForm.name.trim() ||
!fastGptForm.agent
}
onClick={() => void handleSaveFastGpt()}
>
{saving ? (
@@ -1472,35 +1462,14 @@ export function AssistantPage(props: AssistantPageProps) {
<SectionCard
icon={<Database size={18} />}
title="FastGPT 应用配置"
description="填写 App ID、API URL 与 API Key 以对接 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
>
<InputField
label="App ID"
hint={
"FastGPT 应用的 appId用于拉取应用预设开场白等信息。\n在 FastGPT「应用详情 → 发布渠道 / API 访问」中获取。\n留空则不展示应用开场白回退使用本助手自身的开场白。"
}
value={fastGptForm.appId}
onChange={(value) => updateFastGptForm("appId", value)}
placeholder="请输入 FastGPT 应用 ID"
/>
<InputField
label="API URL"
hint={
"填 FastGPT 服务的主机根地址即可,例如 http://localhost:3000。\n系统会自动拼接 /api/v1/chat/completions无需手动带上该路径。\n即使粘贴了完整接口地址也会自动归一,但建议只填根地址。)"
}
value={fastGptForm.apiUrl}
onChange={(value) => updateFastGptForm("apiUrl", value)}
placeholder="http://localhost:3000"
/>
<SecretInputField
label="API Key"
hint={
"FastGPT 为该应用生成的 API Key形如 fastgpt-xxxxx。\n在 FastGPT「应用详情 → API 访问」中创建。每个应用的 Key 独立。"
}
value={fastGptForm.apiKey}
onChange={(value) => updateFastGptForm("apiKey", value)}
placeholder="请输入 FastGPT API Key"
storedValueMask={storedApiKeyMask}
<ResourceSelectField
label="FastGPT 应用"
value={fastGptForm.agent}
onChange={(value) => updateFastGptForm("agent", value)}
options={agentOptions("fastgpt")}
noneLabel="请选择"
/>
</SectionCard>
@@ -1572,7 +1541,12 @@ export function AssistantPage(props: AssistantPageProps) {
)}
<Button
className="gap-2"
disabled={saving || !dirty || !openCodeForm.name.trim()}
disabled={
saving ||
!dirty ||
!openCodeForm.name.trim() ||
!openCodeForm.agent
}
onClick={() => void handleSaveOpenCode()}
>
{saving ? (
@@ -1590,20 +1564,14 @@ export function AssistantPage(props: AssistantPageProps) {
<SectionCard
icon={<Terminal size={18} />}
title="OpenCode 服务配置"
description="填写 OpenCode 服务地址与 API Key 以对接代码助手。"
description="从「模型资源」中选择 OpenCode 服务资源。"
>
<InputField
label="OpenCode URL"
value={openCodeForm.apiUrl}
onChange={(value) => updateOpenCodeForm("apiUrl", value)}
placeholder="http://localhost:4096"
/>
<SecretInputField
label="API Key"
value={openCodeForm.apiKey}
onChange={(value) => updateOpenCodeForm("apiKey", value)}
placeholder="请输入 OpenCode API Key"
storedValueMask={storedApiKeyMask}
<ResourceSelectField
label="OpenCode 服务"
value={openCodeForm.agent}
onChange={(value) => updateOpenCodeForm("agent", value)}
options={agentOptions("opencode")}
noneLabel="请选择"
/>
</SectionCard>

View File

@@ -6,6 +6,7 @@ import {
ChevronUp,
Copy,
Eye,
EyeOff,
Loader2,
MoreHorizontal,
Pencil,
@@ -62,6 +63,7 @@ const capabilities: ModelType[] = [
"TTS",
"Realtime",
"Embedding",
"Agent",
];
const capabilityFilters = ["全部", ...capabilities] as const;
@@ -120,6 +122,15 @@ function fieldValue(draft: ResourceDraft, field: InterfaceField): unknown {
: draft.values[field.key];
}
function secretMask(
masks: Record<string, unknown>,
field: InterfaceField,
): string {
if (field.group !== "secrets") return "";
const value = masks[field.key];
return typeof value === "string" && value.includes("****") ? value : "";
}
function extraBodyText(values: Record<string, unknown>): string {
const value = values.extraBody;
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
@@ -171,6 +182,7 @@ export function ComponentsModelsPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<ResourceDraft>(emptyDraft);
const [storedSecretMasks, setStoredSecretMasks] = useState<Record<string, unknown>>({});
const [extraBodyDraft, setExtraBodyDraft] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
@@ -284,6 +296,7 @@ export function ComponentsModelsPage() {
values: definition ? defaults(definition) : {},
secrets: {},
}));
setStoredSecretMasks({});
setExtraBodyDraft("");
setTestResult(null);
}
@@ -301,6 +314,7 @@ export function ComponentsModelsPage() {
supportImageInput:
capability === "LLM" ? previous.supportImageInput : false,
}));
setStoredSecretMasks({});
setExtraBodyDraft("");
setTestResult(null);
}
@@ -314,8 +328,10 @@ export function ComponentsModelsPage() {
...emptyDraft,
interfaceType: first?.interfaceType ?? "",
values: first ? defaults(first) : {},
secrets: {},
supportImageInput: false,
});
setStoredSecretMasks({});
setExtraBodyDraft("");
setSaveError("");
setTestResult(null);
@@ -329,9 +345,10 @@ export function ComponentsModelsPage() {
capability: resource.capability,
interfaceType: resource.interfaceType,
values: resource.values,
secrets: resource.secrets,
secrets: {},
supportImageInput: resource.supportImageInput,
});
setStoredSecretMasks(resource.secrets);
setExtraBodyDraft(extraBodyText(resource.values));
setSaveError("");
setTestResult(null);
@@ -355,7 +372,10 @@ export function ComponentsModelsPage() {
!extraBodyError &&
selectedDefinition.fieldSchema.fields.every((field) => {
if (!field.required) return true;
const value = fieldValue(draft, field);
const value =
field.group === "secrets" && secretMask(storedSecretMasks, field)
? secretMask(storedSecretMasks, field)
: fieldValue(draft, field);
return value !== undefined && value !== null && value !== "";
}),
);
@@ -369,7 +389,9 @@ export function ComponentsModelsPage() {
draft.capability === "LLM"
? valuesWithExtraBody(draft.values, extraBody)
: draft.values,
secrets: draft.secrets,
secrets: editingId
? { ...storedSecretMasks, ...draft.secrets }
: draft.secrets,
supportImageInput:
draft.capability === "LLM" ? draft.supportImageInput : false,
enabled: editingId
@@ -747,6 +769,7 @@ export function ComponentsModelsPage() {
key={`${field.group}-${field.key}`}
field={field}
value={fieldValue(draft, field)}
storedValueMask={secretMask(storedSecretMasks, field)}
onChange={(value) => updateField(field, value)}
/>
))}
@@ -779,6 +802,7 @@ export function ComponentsModelsPage() {
key={`${field.group}-${field.key}`}
field={field}
value={fieldValue(draft, field)}
storedValueMask={secretMask(storedSecretMasks, field)}
onChange={(value) => updateField(field, value)}
/>
))
@@ -901,12 +925,16 @@ function FieldSection({
function DynamicField({
field,
value,
storedValueMask,
onChange,
}: {
field: InterfaceField;
value: unknown;
storedValueMask?: string;
onChange: (value: unknown) => void;
}) {
const [showPassword, setShowPassword] = useState(false);
if (field.type === "boolean") {
return (
<div className="flex items-center justify-between rounded-xl border border-hairline p-4">
@@ -942,17 +970,63 @@ function DynamicField({
);
}
if (field.type === "password") {
const inputValue = String(value ?? "");
const hasStoredValue = Boolean(storedValueMask);
return (
<Field label={field.label} required={field.required}>
{hasStoredValue && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<code className="rounded-md bg-surface-strong px-2 py-1 font-mono text-foreground">
{storedValueMask}
</code>
</div>
)}
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={inputValue}
onChange={(event) => {
const nextValue = event.target.value;
if (!nextValue) setShowPassword(false);
onChange(nextValue);
}}
placeholder={
hasStoredValue ? "已配置,留空则保持不变" : undefined
}
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
disabled={!inputValue}
onClick={() => setShowPassword((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
aria-label={showPassword ? "隐藏密钥" : "显示密钥"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{hasStoredValue && (
<p className="text-xs leading-5 text-muted-foreground">
</p>
)}
</Field>
);
}
return (
<Field label={field.label} required={field.required}>
<Input
type={
field.type === "password"
? "password"
: field.type === "number"
? "number"
: field.type === "url"
? "url"
: "text"
field.type === "number"
? "number"
: field.type === "url"
? "url"
: "text"
}
value={String(value ?? "")}
onChange={(event) =>
@@ -962,11 +1036,6 @@ function DynamicField({
: event.target.value,
)
}
placeholder={
field.type === "password" && String(value ?? "").includes("****")
? "已配置,留空或保留掩码表示不修改"
: undefined
}
/>
</Field>
);