Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 152 additions & 2 deletions app/apps/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
import { Checkbox } from "@/components/ui/checkbox";
import {
ArrowLeft,
Braces,
Copy,
Loader2,
Plus,
Send,
Expand All @@ -47,7 +49,8 @@ import {
TrendingUp,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useAppTools, useAppToolsStatistics } from "@/hooks/useAppTools";
import { useAppTools, useAppToolsStatistics, type AppTool } from "@/hooks/useAppTools";
import { Textarea } from "@/components/ui/textarea";
import { useInvalidateAppSkillDiagnostics } from "@/hooks/useAppSkillDiagnostics";
import AppSkillsSection from "../components/AppSkillsSection";
import AppSkillDiagnostics from "../components/AppSkillDiagnostics";
Expand Down Expand Up @@ -124,6 +127,7 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
const [bindDialogOpen, setBindDialogOpen] = useState(false);
const [unbindDialogOpen, setUnbindDialogOpen] = useState(false);
const [selectedAppToolId, setSelectedAppToolId] = useState<number | null>(null);
const [editingTool, setEditingTool] = useState<AppTool | null>(null);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [reviewActionPending, setReviewActionPending] = useState(false);
const [reviewLogOpen, setReviewLogOpen] = useState(false);
Expand All @@ -132,6 +136,7 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
tools: appTools,
loading: appToolsLoading,
unbindTool,
updateAppTool,
refresh: refreshAppTools,
} = useAppTools(appId);
const { statistics } = useAppToolsStatistics(appId);
Expand Down Expand Up @@ -512,7 +517,14 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
<TableBody>
{appTools.map((tool) => (
<TableRow key={tool.id}>
<TableCell className="font-medium">{tool.tool_display_name}</TableCell>
<TableCell className="font-medium">
{tool.tool_display_name}
{Object.keys(tool.custom_config || {}).length > 0 && (
<Badge variant="outline" className="ml-2 text-xs">
{t("hasCustomConfig")}
</Badge>
)}
</TableCell>
<TableCell>
<Badge
className={toolTypeClass(tool.tool_type)}
Expand Down Expand Up @@ -548,6 +560,14 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
<div className="flex items-center justify-end gap-2">
{canEditThisApp && (
<>
<Button
variant="ghost"
size="sm"
title={t("editToolConfig")}
onClick={() => setEditingTool(tool)}
>
<Braces className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
Expand Down Expand Up @@ -618,6 +638,18 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* 编辑工具自定义配置对话框 */}
{editingTool && (
<EditToolConfigDialog
tool={editingTool}
onClose={() => setEditingTool(null)}
onSave={async (customConfig) => {
const ok = await updateAppTool(editingTool.id, customConfig);
if (ok) setEditingTool(null);
}}
/>
)}
</div>
);
}
Expand Down Expand Up @@ -747,3 +779,121 @@ function BindToolsDialog({
</Dialog>
);
}

// 编辑工具自定义配置对话框。
// custom_config 与 default_config 浅合并(顶层键覆盖):改 headers 就要写整个
// headers 对象,不是只写变化的那一项。密钥值用 ${ENV_VAR} 引用环境变量名,
// 不要把 token 字面值贴进来——值属于部署环境的 .env。
function EditToolConfigDialog({
tool,
onClose,
onSave,
}: {
tool: AppTool;
onClose: () => void;
onSave: (customConfig: Record<string, any>) => Promise<unknown>;
}) {
const t = useTranslations("apps");
const tc = useTranslations("common");
const [text, setText] = useState(() => JSON.stringify(tool.custom_config || {}, null, 2));
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);

const handleSave = async () => {
let parsed: Record<string, any>;
try {
parsed = JSON.parse(text);
} catch (e: any) {
setError(`${t("invalidJson")}: ${e.message}`);
return;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
setError(t("configMustBeObject"));
return;
}
setError(null);
setSaving(true);
await onSave(parsed);
setSaving(false);
};

return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl gap-4 max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("editToolConfig")} · {tool.tool_display_name}
</DialogTitle>
</DialogHeader>

<div className="space-y-3">
<div className="text-sm text-muted-foreground">{t("customConfigMergeHint")}</div>

{Object.keys(tool.default_config || {}).length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-1 flex items-center gap-2">
<span>{t("toolDefaultConfig")}</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={async () => {
try {
await navigator.clipboard.writeText(
JSON.stringify(tool.default_config, null, 2)
);
toast.success(t("copiedToClipboard"));
} catch {
toast.error(tc("copyFailed"));
}
}}
>
<Copy className="h-3 w-3 mr-1" />
{t("copyDefaultConfig")}
</Button>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto max-h-40">
{JSON.stringify(tool.default_config, null, 2)}
</pre>
</div>
)}

<div>
<div className="text-xs font-medium text-muted-foreground mb-1 flex items-center gap-2">
<span>{t("customConfigJson")}</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setText("{}");
setError(null);
}}
>
{t("resetToDefaultConfig")}
</Button>
</div>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
className="font-mono text-xs resize-y"
style={{ minHeight: 240 }}
spellCheck={false}
/>
{error && <div className="text-xs text-destructive mt-1">{error}</div>}
</div>
</div>

<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
{tc("cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{tc("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
24 changes: 24 additions & 0 deletions hooks/useAppTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ export const useAppTools = (appId: number | null, isEnabled?: boolean) => {
}
};

// 更新应用的工具配置(custom_config 覆盖 default_config,浅合并)
const updateAppTool = async (
appToolId: number,
customConfig?: Record<string, any>,
priority?: number
) => {
if (!appId) return false;

try {
await axios.put(`/api/apps/${appId}/tools/${appToolId}`, {
custom_config: customConfig ?? {},
...(priority !== undefined ? { priority } : {}),
});
toast.success("工具配置已更新");
mutate();
return true;
} catch (error: any) {
console.error("Update app tool error:", error);
toast.error(error.response?.data?.error || error.response?.data?.detail || "更新工具配置失败");
return false;
}
};

// 解绑工具
const unbindTool = async (appToolId: number) => {
if (!appId) return false;
Expand All @@ -125,6 +148,7 @@ export const useAppTools = (appId: number | null, isEnabled?: boolean) => {
error,
bindTool,
batchBindTools,
updateAppTool,
unbindTool,
refresh: mutate,
};
Expand Down
10 changes: 10 additions & 0 deletions messages/en/apps.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@
"confirmUnbindTool": "Confirm Unbind Tool",
"unbindToolWarning": "Are you sure you want to unbind this tool? After unbinding, this digital employee will not be able to use this tool.",
"unbind": "Unbind",
"editToolConfig": "Edit Tool Config",
"hasCustomConfig": "Custom",
"customConfigMergeHint": "Top-level keys in custom config override the tool's default config: e.g. overriding headers requires the complete headers object. Secrets can be entered directly, or reference an environment variable via $'{ENV_VAR}'.",
"toolDefaultConfig": "Default config (read-only)",
"copyDefaultConfig": "Copy",
"copiedToClipboard": "Copied to clipboard",
"customConfigJson": "Custom config (JSON; empty $'{}' means using default config)",
"resetToDefaultConfig": "Clear & use default config",
"configMustBeObject": "Config must be a JSON object ($'{...}'), not an array or scalar",
"invalidJson": "Invalid JSON",
"allToolsBound": "All available tools have been bound",
"bind": "Bind",
"bindCount": "Bind ({count})",
Expand Down
10 changes: 10 additions & 0 deletions messages/zh-CN/apps.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@
"confirmUnbindTool": "确认解绑工具",
"unbindToolWarning": "确定要解绑该工具吗?解绑后,该数字员工将无法使用此工具。",
"unbind": "解绑",
"editToolConfig": "编辑工具配置",
"hasCustomConfig": "自定义",
"customConfigMergeHint": "自定义配置的顶层键会覆盖工具默认配置:例如覆盖 headers 就要写完整的 headers 对象。密钥值可以直接填写,也可以写 $'{ENV_VAR}' 引用环境变量。",
"toolDefaultConfig": "默认配置(只读)",
"copyDefaultConfig": "复制",
"copiedToClipboard": "已复制到剪贴板",
"customConfigJson": "自定义配置(JSON,留空 $'{}' 表示使用默认配置)",
"resetToDefaultConfig": "清空,恢复用默认配置",
"configMustBeObject": "配置必须是一个 JSON 对象($'{...}'),不能是数组或标量",
"invalidJson": "JSON 格式错误",
"allToolsBound": "所有可用工具都已绑定",
"bind": "绑定",
"bindCount": "绑定 ({count})",
Expand Down
21 changes: 20 additions & 1 deletion pages/api/apps/[id]/tools/[toolId].ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,33 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}

switch (method) {
case "PUT": {
// 更新应用的工具配置(custom_config / priority)。
// 权限(owner 或超管)由后端 require_app_owner_or_super 校验,
// 代理只做身份透传——这里重复实现一遍所有权判断只会两处漂移。
const body = typeof req.body === "string" ? JSON.parse(req.body) : req.body || {};
const payload: Record<string, unknown> = {};
if (body.custom_config !== undefined) payload.custom_config = body.custom_config;
if (body.priority !== undefined) payload.priority = body.priority;
if (Object.keys(payload).length === 0) {
return res.status(400).json({ error: "custom_config 或 priority 至少传一项" });
}
const updated = await axios.put(
`${BACKEND_URL}/api/v1/apps/${appId}/tools/${toolId}`,
payload,
{ headers }
);
return res.status(200).json(updated.data);
}

case "DELETE": {
// 解绑工具
await axios.delete(`${BACKEND_URL}/api/v1/apps/${appId}/tools/${toolId}`, { headers });
return res.status(200).json({ message: "Tool unbound successfully" });
}

default:
res.setHeader("Allow", ["DELETE"]);
res.setHeader("Allow", ["PUT", "DELETE"]);
return res.status(405).json({ error: `Method ${method} Not Allowed` });
}
} catch (error: any) {
Expand Down
Loading