mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
Feat/web UI fixes v2 (#2152)
* fix(web): 修复复制按钮和插件安装对话框UI问题 - 新增 clipboard.ts 工具函数支持 Clipboard API 降级 - 修复添加机器人页面 Webhook URL 复制按钮未生效 - 修复 API 集成对话框 API Key 复制按钮未生效 - 修复 Bot 会话监控用户 ID 复制按钮未生效 - 修复插件安装进度状态框横向溢出和小屏缩放问题 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): improve clipboard copy with Selection API fallback Replace navigator.clipboard.writeText with Selection API + execCommand for reliable copying in non-secure contexts. Remove duplicate dialog. Fix scanProviderModels type signature to accept rerank model type. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): revert package-lock.json to match upstream Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): fix prettier formatting errors Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): unify all clipboard copy to use copyToClipboard utility - Fix embed code copy button not working in non-secure contexts - Add copy animation (check icon) to embed code button via EmbedCodeField component - Replace raw navigator.clipboard calls in plugins/page.tsx and BotLogCard.tsx - Remove duplicated inline fallback implementations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
|
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
|
|
||||||
const LEVEL_STYLES: Record<string, string> = {
|
const LEVEL_STYLES: Record<string, string> = {
|
||||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||||
@@ -31,36 +32,19 @@ export function BotLogCard({
|
|||||||
|
|
||||||
function copySessionId() {
|
function copySessionId() {
|
||||||
const text = botLog.message_session_id;
|
const text = botLog.message_session_id;
|
||||||
if (navigator.clipboard?.writeText) {
|
copyToClipboard(text)
|
||||||
navigator.clipboard
|
.then((ok) => {
|
||||||
.writeText(text)
|
if (ok) {
|
||||||
.then(() => {
|
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
toast.success(t('common.copySuccess'));
|
toast.success(t('common.copySuccess'));
|
||||||
})
|
} else {
|
||||||
.catch(() => fallbackCopy(text));
|
toast.error(t('common.copyFailed'));
|
||||||
} else {
|
}
|
||||||
fallbackCopy(text);
|
})
|
||||||
}
|
.catch(() => {
|
||||||
}
|
toast.error(t('common.copyFailed'));
|
||||||
|
});
|
||||||
function fallbackCopy(text: string) {
|
|
||||||
const ta = document.createElement('textarea');
|
|
||||||
ta.value = text;
|
|
||||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.focus();
|
|
||||||
ta.select();
|
|
||||||
try {
|
|
||||||
document.execCommand('copy');
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
toast.success(t('common.copySuccess'));
|
|
||||||
} catch {
|
|
||||||
toast.error(t('common.copyFailed'));
|
|
||||||
}
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: number) {
|
function formatTime(timestamp: number) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
Plain,
|
Plain,
|
||||||
@@ -108,10 +109,9 @@ const BotSessionMonitor = forwardRef<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copyUserId = (userId: string) => {
|
const copyUserId = (userId: string) => {
|
||||||
navigator.clipboard.writeText(userId).then(() => {
|
copyToClipboard(userId).catch(() => {});
|
||||||
setCopiedUserId(true);
|
setCopiedUserId(true);
|
||||||
setTimeout(() => setCopiedUserId(false), 2000);
|
setTimeout(() => setCopiedUserId(false), 2000);
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||||
@@ -86,6 +86,9 @@ export default function ApiIntegrationDialog({
|
|||||||
const [newWebhookDescription, setNewWebhookDescription] = useState('');
|
const [newWebhookDescription, setNewWebhookDescription] = useState('');
|
||||||
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
|
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
|
||||||
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
|
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
|
||||||
|
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
// Sync URL with dialog state
|
// Sync URL with dialog state
|
||||||
@@ -182,10 +185,29 @@ export default function ApiIntegrationDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.textContent = text;
|
||||||
|
el.style.cssText =
|
||||||
|
'position:fixed;opacity:0;pointer-events:none;white-space:pre;';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('copy');
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
document.body.removeChild(el);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopyKey = (key: string) => {
|
const handleCopyKey = (key: string) => {
|
||||||
navigator.clipboard.writeText(key);
|
try {
|
||||||
|
copyToClipboard(key);
|
||||||
|
} catch {}
|
||||||
|
clearTimeout(copiedTimerRef.current);
|
||||||
setCopiedKey(key);
|
setCopiedKey(key);
|
||||||
setTimeout(() => setCopiedKey(null), 2000);
|
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const maskApiKey = (key: string) => {
|
const maskApiKey = (key: string) => {
|
||||||
@@ -330,21 +352,21 @@ export default function ApiIntegrationDialog({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{apiKeys.map((key) => (
|
{apiKeys.map((item) => (
|
||||||
<TableRow key={key.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{key.name}</div>
|
<div className="font-medium">{item.name}</div>
|
||||||
{key.description && (
|
{item.description && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{key.description}
|
{item.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||||
{maskApiKey(key.key)}
|
{maskApiKey(item.key)}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -352,10 +374,11 @@ export default function ApiIntegrationDialog({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCopyKey(key.key)}
|
type="button"
|
||||||
|
onClick={() => handleCopyKey(item.key)}
|
||||||
title={t('common.copyApiKey')}
|
title={t('common.copyApiKey')}
|
||||||
>
|
>
|
||||||
{copiedKey === key.key ? (
|
{copiedKey === item.key ? (
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
@@ -364,7 +387,7 @@ export default function ApiIntegrationDialog({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setDeleteKeyId(key.id)}
|
onClick={() => setDeleteKeyId(item.id)}
|
||||||
title={t('common.delete')}
|
title={t('common.delete')}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -630,44 +653,6 @@ export default function ApiIntegrationDialog({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete API Key Confirmation Dialog */}
|
|
||||||
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t('common.apiKeyCreatedMessage')}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{t('common.apiKeyValue')}
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2 mt-1">
|
|
||||||
<Input value={createdKey?.key || ''} readOnly />
|
|
||||||
<Button
|
|
||||||
onClick={() => createdKey && handleCopyKey(createdKey.key)}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
>
|
|
||||||
{copiedKey === createdKey?.key ? (
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={() => setCreatedKey(null)}>
|
|
||||||
{t('common.close')}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog open={!!deleteKeyId}>
|
<AlertDialog open={!!deleteKeyId}>
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Copy, Check, Globe } from 'lucide-react';
|
import { Copy, Check, Globe } from 'lucide-react';
|
||||||
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import { systemInfo } from '@/app/infra/http';
|
import { systemInfo } from '@/app/infra/http';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +45,54 @@ function resolveShowIfValue(
|
|||||||
return externalDependentValues?.[field];
|
return externalDependentValues?.[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-only component for embed code fields with copy animation.
|
||||||
|
*/
|
||||||
|
function EmbedCodeField({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
snippet,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
snippet: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
copyToClipboard(snippet).catch(() => {});
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none">{label}</label>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
|
||||||
|
<code>{snippet}</code>
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display-only component for webhook URL fields.
|
* Display-only component for webhook URL fields.
|
||||||
* Rendered outside of react-hook-form binding since the value is
|
* Rendered outside of react-hook-form binding since the value is
|
||||||
@@ -65,15 +114,9 @@ function WebhookUrlField({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleCopy = (text: string, setter: (v: boolean) => void) => {
|
const handleCopy = (text: string, setter: (v: boolean) => void) => {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
copyToClipboard(text).catch(() => {});
|
||||||
navigator.clipboard
|
setter(true);
|
||||||
.writeText(text)
|
setTimeout(() => setter(false), 2000);
|
||||||
.then(() => {
|
|
||||||
setter(true);
|
|
||||||
setTimeout(() => setter(false), 2000);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -467,32 +510,16 @@ export default function DynamicFormComponent({
|
|||||||
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={config.id} className="space-y-2">
|
<EmbedCodeField
|
||||||
<label className="text-sm font-medium leading-none">
|
key={config.id}
|
||||||
{extractI18nObject(config.label)}
|
label={extractI18nObject(config.label)}
|
||||||
</label>
|
description={
|
||||||
{config.description && (
|
config.description
|
||||||
<p className="text-sm text-muted-foreground">
|
? extractI18nObject(config.description)
|
||||||
{extractI18nObject(config.description)}
|
: undefined
|
||||||
</p>
|
}
|
||||||
)}
|
snippet={embedSnippet}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
|
|
||||||
<code>{embedSnippet}</code>
|
|
||||||
</pre>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(embedSnippet);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function StageRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
|
'flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg transition-all duration-300',
|
||||||
isActive &&
|
isActive &&
|
||||||
!isError &&
|
!isError &&
|
||||||
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
|
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
|
||||||
@@ -154,7 +154,7 @@ function StageRow({
|
|||||||
{detail && (
|
{detail && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs mt-0.5',
|
'text-xs mt-0.5 break-words',
|
||||||
isCompleted
|
isCompleted
|
||||||
? 'text-green-600/70 dark:text-green-400/70'
|
? 'text-green-600/70 dark:text-green-400/70'
|
||||||
: 'text-blue-600/70 dark:text-blue-400/70',
|
: 'text-blue-600/70 dark:text-blue-400/70',
|
||||||
@@ -256,7 +256,7 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
|||||||
<>
|
<>
|
||||||
<span>{parts.join(' · ')}</span>
|
<span>{parts.join(' · ')}</span>
|
||||||
<br />
|
<br />
|
||||||
<span className="opacity-70">{currentDep}</span>
|
<span className="opacity-70 break-words">{currentDep}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -277,10 +277,10 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Overall progress bar — always blue */}
|
{/* Overall progress bar — always blue */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-sm font-medium',
|
'text-sm font-medium shrink-0',
|
||||||
isDone
|
isDone
|
||||||
? 'text-green-700 dark:text-green-300'
|
? 'text-green-700 dark:text-green-300'
|
||||||
: 'text-blue-700 dark:text-blue-300',
|
: 'text-blue-700 dark:text-blue-300',
|
||||||
@@ -360,8 +360,8 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
|||||||
{/* Done banner */}
|
{/* Done banner */}
|
||||||
{isDone && (
|
{isDone && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
|
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
|
||||||
<span className="text-sm text-green-700 dark:text-green-300 font-medium">
|
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
|
||||||
{t('plugins.installProgress.installComplete')}
|
{t('plugins.installProgress.installComplete')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,13 +406,13 @@ export default function PluginInstallProgressDialog() {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
|
className="sm:max-w-lg w-[90vw] max-h-[80vh] p-4 sm:p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto overflow-x-hidden"
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<DialogTitle className="flex items-start gap-3">
|
||||||
<Download className="size-5" />
|
<Download className="size-5 shrink-0 mt-0.5" />
|
||||||
<span className="truncate">
|
<span className="break-words">
|
||||||
{selectedTask
|
{selectedTask
|
||||||
? t('plugins.installProgress.title', {
|
? t('plugins.installProgress.title', {
|
||||||
name: selectedTask.pluginName,
|
name: selectedTask.pluginName,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Bug,
|
Bug,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -466,33 +467,13 @@ function PluginListView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => {
|
const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => {
|
||||||
try {
|
copyToClipboard(text).catch(() => {});
|
||||||
navigator.clipboard.writeText(text);
|
if (type === 'url') {
|
||||||
if (type === 'url') {
|
setCopiedDebugUrl(true);
|
||||||
setCopiedDebugUrl(true);
|
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
||||||
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
} else {
|
||||||
} else {
|
setCopiedDebugKey(true);
|
||||||
setCopiedDebugKey(true);
|
setTimeout(() => setCopiedDebugKey(false), 2000);
|
||||||
setTimeout(() => setCopiedDebugKey(false), 2000);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = text;
|
|
||||||
textArea.style.position = 'fixed';
|
|
||||||
textArea.style.left = '-999999px';
|
|
||||||
textArea.style.top = '-999999px';
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.select();
|
|
||||||
textArea.setSelectionRange(0, 99999);
|
|
||||||
const success = document.execCommand('copy');
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
if (success) {
|
|
||||||
setCopiedDebugUrl(true);
|
|
||||||
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
|
||||||
} else {
|
|
||||||
setCopiedDebugKey(true);
|
|
||||||
setTimeout(() => setCopiedDebugKey(false), 2000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
|
|
||||||
public scanProviderModels(
|
public scanProviderModels(
|
||||||
uuid: string,
|
uuid: string,
|
||||||
modelType?: 'llm' | 'embedding',
|
modelType?: 'llm' | 'embedding' | 'rerank',
|
||||||
): Promise<ApiRespScannedProviderModels> {
|
): Promise<ApiRespScannedProviderModels> {
|
||||||
const params = modelType ? { type: modelType } : {};
|
const params = modelType ? { type: modelType } : {};
|
||||||
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);
|
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);
|
||||||
|
|||||||
39
web/src/app/utils/clipboard.ts
Normal file
39
web/src/app/utils/clipboard.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Copy text to clipboard with fallback support
|
||||||
|
* Tries to use modern Clipboard API first, falls back to execCommand if not available
|
||||||
|
*
|
||||||
|
* @param text - The text to copy to clipboard
|
||||||
|
* @returns Promise<boolean> - true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
// Try modern Clipboard API first
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Clipboard] Modern API failed, trying fallback:', err);
|
||||||
|
// Fall through to legacy method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy execCommand method
|
||||||
|
try {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
return successful;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Clipboard] Fallback method failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user