mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 12:16:54 +00:00
refactor(ui): modularize web UI components — Phase 1 & 2
Split 19 large files (300-874 lines) into 37 focused modules. All files target ≤200 lines for optimal maintainability. Phase 1 (>400 lines): provider-overview, use-chat-messages, system-settings-modal, trace-detail-dialog, media-provider-chain-form, cli-credential-form-dialog, team-settings-tab, skills-page, heartbeat-config-dialog, mcp-grants-dialog. Phase 2 (200-400 lines): builtin-tools-page, quota-section, file-tree, rich-content, session-detail-page, ai-defaults-section, markdown-renderer, file-viewers, channel-managers-tab. Also fixed DRY violations: exported shared types (VerifyResult, ReasoningCapability, ManualEnvEntry), resolved circular import in media-provider-chain files. No behavior changes — pure refactoring. Build passes.
This commit is contained in:
1 parent
b2fda38e90
commit
1187146626
56 files changed
+4418
-4688
No files matched your search
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Parser and deduplicator for chat message rich content blocks.
|
||||
* Extracts special markup (media tags, file blocks, forwarded messages, etc.)
|
||||
* into structured RichBlock objects for rendering.
|
||||
*/
|
||||
|
||||
// <media:image>, <media:video>, <media:audio>, <media:voice>, <media:document>
|
||||
const MEDIA_TAG_RE = /<media:(image|video|audio|voice|document|animation)>/g;
|
||||
|
||||
// <file name="..." mime="...">content</file>
|
||||
const FILE_BLOCK_RE = /<file\s+name="([^"]+)"\s+mime="([^"]*)">([\s\S]*?)<\/file>/g;
|
||||
|
||||
// [Forwarded from Name at Date] at the start
|
||||
const FORWARD_RE = /^\[Forwarded from (.+?) at (.+?)\]\n?/;
|
||||
|
||||
// [Replying to Sender]\nbody\n[/Replying]
|
||||
const REPLY_RE = /\[Replying to (.+?)\]\n([\s\S]*?)\n\[\/Replying\]/;
|
||||
|
||||
// [Video received — ...]
|
||||
const VIDEO_NOTICE_RE = /\[Video received[^\]]*\]/g;
|
||||
|
||||
// Location: Coordinates: lat, lng
|
||||
const LOCATION_RE = /Coordinates:\s*([-\d.]+),\s*([-\d.]+)/;
|
||||
|
||||
export type RichBlock =
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: "media"; mediaType: string }
|
||||
| { type: "video-notice"; content: string }
|
||||
| { type: "file"; name: string; mime: string; content: string }
|
||||
| { type: "forward"; from: string; date: string }
|
||||
| { type: "reply"; sender: string; body: string }
|
||||
| { type: "location"; lat: string; lng: string };
|
||||
|
||||
/** Parse message content into rich blocks for rendering */
|
||||
export function parseRichContent(content: string): RichBlock[] {
|
||||
const blocks: RichBlock[] = [];
|
||||
let text = content;
|
||||
|
||||
// Extract forward info (always at start)
|
||||
const fwdMatch = text.match(FORWARD_RE);
|
||||
if (fwdMatch) {
|
||||
blocks.push({ type: "forward", from: fwdMatch[1]!, date: fwdMatch[2]! });
|
||||
text = text.slice(fwdMatch[0].length);
|
||||
}
|
||||
|
||||
// Extract reply block
|
||||
const replyMatch = text.match(REPLY_RE);
|
||||
let replyBlock: RichBlock | null = null;
|
||||
if (replyMatch) {
|
||||
replyBlock = { type: "reply", sender: replyMatch[1]!, body: replyMatch[2]! };
|
||||
text = text.replace(REPLY_RE, "");
|
||||
}
|
||||
|
||||
// Extract file blocks
|
||||
const fileBlocks: RichBlock[] = [];
|
||||
text = text.replace(FILE_BLOCK_RE, (_match, name: string, mime: string, body: string) => {
|
||||
fileBlocks.push({ type: "file", name, mime, content: body });
|
||||
return "";
|
||||
});
|
||||
|
||||
// Extract media tags
|
||||
const mediaBlocks: RichBlock[] = [];
|
||||
text = text.replace(MEDIA_TAG_RE, (_match, mediaType: string) => {
|
||||
mediaBlocks.push({ type: "media", mediaType });
|
||||
return "";
|
||||
});
|
||||
|
||||
// Extract video notices
|
||||
text = text.replace(VIDEO_NOTICE_RE, (match) => {
|
||||
mediaBlocks.push({ type: "video-notice", content: match });
|
||||
return "";
|
||||
});
|
||||
|
||||
// Extract location
|
||||
const locMatch = text.match(LOCATION_RE);
|
||||
let locationBlock: RichBlock | null = null;
|
||||
if (locMatch) {
|
||||
locationBlock = { type: "location", lat: locMatch[1]!, lng: locMatch[2]! };
|
||||
text = text.replace(LOCATION_RE, "");
|
||||
}
|
||||
|
||||
// Build final block list: forward → media → markdown → files → reply → location
|
||||
if (mediaBlocks.length > 0) blocks.push(...mediaBlocks);
|
||||
|
||||
// Clean up leftover whitespace
|
||||
const trimmed = text.replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (trimmed) blocks.push({ type: "markdown", content: trimmed });
|
||||
|
||||
if (fileBlocks.length > 0) blocks.push(...fileBlocks);
|
||||
if (replyBlock) blocks.push(replyBlock);
|
||||
if (locationBlock) blocks.push(locationBlock);
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Extract basename from a markdown link/image URL, stripping query params. */
|
||||
function linkBasename(line: string): string | null {
|
||||
const m = line.match(/\]\(([^)]+)\)/);
|
||||
if (!m?.[1]) return null;
|
||||
const url = m[1].split("?")[0] ?? "";
|
||||
const slash = url.lastIndexOf("/");
|
||||
return slash >= 0 ? url.slice(slash + 1) : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate media links from content. When the same file (by basename)
|
||||
* appears in both the agent's body text and an appended ContentSuffix block,
|
||||
* keep the first occurrence and drop later duplicates.
|
||||
*/
|
||||
export function deduplicateMediaLinks(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
// Pass 1: collect first occurrence index of each media basename (inline or standalone).
|
||||
const firstSeen = new Map<string, number>();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
if (!line.includes("](/")) continue;
|
||||
const base = linkBasename(line);
|
||||
if (base && !firstSeen.has(base)) firstSeen.set(base, i);
|
||||
}
|
||||
// Pass 2: drop STANDALONE link/image lines whose basename appeared in an earlier line.
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
const isStandaloneLink = (trimmed.startsWith(" && trimmed.endsWith(")");
|
||||
if (isStandaloneLink) {
|
||||
const base = linkBasename(trimmed);
|
||||
if (base) {
|
||||
if (seen.has(base)) continue; // duplicate standalone — drop
|
||||
seen.add(base);
|
||||
// Also skip if this basename appeared inline in an earlier line
|
||||
const first = firstSeen.get(base);
|
||||
if (first !== undefined && first < i) continue;
|
||||
}
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
@@ -18,109 +18,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// --- Patterns to detect special blocks ---
|
||||
|
||||
// <media:image>, <media:video>, <media:audio>, <media:voice>, <media:document>
|
||||
const MEDIA_TAG_RE = /<media:(image|video|audio|voice|document|animation)>/g;
|
||||
|
||||
// <file name="..." mime="...">content</file>
|
||||
const FILE_BLOCK_RE = /<file\s+name="([^"]+)"\s+mime="([^"]*)">([\s\S]*?)<\/file>/g;
|
||||
|
||||
// [Forwarded from Name at Date] at the start
|
||||
const FORWARD_RE = /^\[Forwarded from (.+?) at (.+?)\]\n?/;
|
||||
|
||||
// [Replying to Sender]\nbody\n[/Replying]
|
||||
const REPLY_RE = /\[Replying to (.+?)\]\n([\s\S]*?)\n\[\/Replying\]/;
|
||||
|
||||
// [Video received — ...]
|
||||
const VIDEO_NOTICE_RE = /\[Video received[^\]]*\]/g;
|
||||
|
||||
// Location: Coordinates: lat, lng
|
||||
const LOCATION_RE = /Coordinates:\s*([-\d.]+),\s*([-\d.]+)/;
|
||||
|
||||
type RichBlock =
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: "media"; mediaType: string }
|
||||
| { type: "video-notice"; content: string }
|
||||
| { type: "file"; name: string; mime: string; content: string }
|
||||
| { type: "forward"; from: string; date: string }
|
||||
| { type: "reply"; sender: string; body: string }
|
||||
| { type: "location"; lat: string; lng: string };
|
||||
|
||||
/** Parse message content into rich blocks for rendering */
|
||||
function parseRichContent(content: string): RichBlock[] {
|
||||
const blocks: RichBlock[] = [];
|
||||
let text = content;
|
||||
|
||||
// Extract forward info (always at start)
|
||||
const fwdMatch = text.match(FORWARD_RE);
|
||||
if (fwdMatch) {
|
||||
blocks.push({ type: "forward", from: fwdMatch[1]!, date: fwdMatch[2]! });
|
||||
text = text.slice(fwdMatch[0].length);
|
||||
}
|
||||
|
||||
// Extract reply block
|
||||
const replyMatch = text.match(REPLY_RE);
|
||||
let replyBlock: RichBlock | null = null;
|
||||
if (replyMatch) {
|
||||
replyBlock = { type: "reply", sender: replyMatch[1]!, body: replyMatch[2]! };
|
||||
text = text.replace(REPLY_RE, "");
|
||||
}
|
||||
|
||||
// Extract file blocks
|
||||
const fileBlocks: RichBlock[] = [];
|
||||
text = text.replace(FILE_BLOCK_RE, (_match, name: string, mime: string, body: string) => {
|
||||
fileBlocks.push({ type: "file", name, mime, content: body });
|
||||
return "";
|
||||
});
|
||||
|
||||
// Extract media tags
|
||||
const mediaBlocks: RichBlock[] = [];
|
||||
text = text.replace(MEDIA_TAG_RE, (_match, mediaType: string) => {
|
||||
mediaBlocks.push({ type: "media", mediaType });
|
||||
return "";
|
||||
});
|
||||
|
||||
// Extract video notices
|
||||
text = text.replace(VIDEO_NOTICE_RE, (match) => {
|
||||
mediaBlocks.push({ type: "video-notice", content: match });
|
||||
return "";
|
||||
});
|
||||
|
||||
// Extract location
|
||||
const locMatch = text.match(LOCATION_RE);
|
||||
let locationBlock: RichBlock | null = null;
|
||||
if (locMatch) {
|
||||
locationBlock = { type: "location", lat: locMatch[1]!, lng: locMatch[2]! };
|
||||
text = text.replace(LOCATION_RE, "");
|
||||
}
|
||||
|
||||
// Build final block list: forward → media → markdown → files → reply → location
|
||||
if (mediaBlocks.length > 0) {
|
||||
blocks.push(...mediaBlocks);
|
||||
}
|
||||
|
||||
// Clean up leftover whitespace
|
||||
const trimmed = text.replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (trimmed) {
|
||||
blocks.push({ type: "markdown", content: trimmed });
|
||||
}
|
||||
|
||||
if (fileBlocks.length > 0) {
|
||||
blocks.push(...fileBlocks);
|
||||
}
|
||||
|
||||
if (replyBlock) {
|
||||
blocks.push(replyBlock);
|
||||
}
|
||||
|
||||
if (locationBlock) {
|
||||
blocks.push(locationBlock);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
import { parseRichContent, deduplicateMediaLinks } from "./rich-content-parser";
|
||||
|
||||
// --- Renderers for each block type ---
|
||||
|
||||
@@ -253,55 +151,6 @@ function VideoNoticeBadge({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Media link dedup ---
|
||||
|
||||
/** Extract basename from a markdown link/image URL, stripping query params. */
|
||||
function linkBasename(line: string): string | null {
|
||||
const m = line.match(/\]\(([^)]+)\)/);
|
||||
if (!m?.[1]) return null;
|
||||
const url = m[1].split("?")[0] ?? "";
|
||||
const slash = url.lastIndexOf("/");
|
||||
return slash >= 0 ? url.slice(slash + 1) : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate media links from content. When the same file (by basename)
|
||||
* appears in both the agent's body text and an appended ContentSuffix block,
|
||||
* keep the first occurrence and drop later duplicates.
|
||||
*/
|
||||
function deduplicateMediaLinks(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
// Pass 1: collect first occurrence index of each media basename (inline or standalone).
|
||||
const firstSeen = new Map<string, number>();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
if (!line.includes("](/")) continue;
|
||||
const base = linkBasename(line);
|
||||
if (base && !firstSeen.has(base)) firstSeen.set(base, i);
|
||||
}
|
||||
// Pass 2: drop STANDALONE link/image lines whose basename appeared in an earlier line.
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
const isStandaloneLink = (trimmed.startsWith(" && trimmed.endsWith(")");
|
||||
if (isStandaloneLink) {
|
||||
const base = linkBasename(trimmed);
|
||||
if (base) {
|
||||
if (seen.has(base)) continue; // duplicate standalone — drop
|
||||
seen.add(base);
|
||||
// Also skip if this basename appeared inline in an earlier line
|
||||
const first = firstSeen.get(base);
|
||||
if (first !== undefined && first < i) continue;
|
||||
}
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
interface RichContentProps {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, Clock, Hash, Info } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
|
||||
interface SystemSettingsCompactionCardProps {
|
||||
compProvider: string;
|
||||
setCompProvider: (v: string) => void;
|
||||
compModel: string;
|
||||
setCompModel: (v: string) => void;
|
||||
compThreshold: string;
|
||||
setCompThreshold: (v: string) => void;
|
||||
compKeepRecent: string;
|
||||
setCompKeepRecent: (v: string) => void;
|
||||
compMaxTokens: string;
|
||||
setCompMaxTokens: (v: string) => void;
|
||||
}
|
||||
|
||||
export function SystemSettingsCompactionCard({
|
||||
compProvider, setCompProvider,
|
||||
compModel, setCompModel,
|
||||
compThreshold, setCompThreshold,
|
||||
compKeepRecent, setCompKeepRecent,
|
||||
compMaxTokens, setCompMaxTokens,
|
||||
}: SystemSettingsCompactionCardProps) {
|
||||
const { t } = useTranslation("system-settings");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("compaction.title")}</CardTitle>
|
||||
<CardDescription>{t("compaction.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0 pt-0">
|
||||
<div className="border-b pb-4">
|
||||
<ProviderModelSelect
|
||||
provider={compProvider}
|
||||
onProviderChange={setCompProvider}
|
||||
model={compModel}
|
||||
onModelChange={setCompModel}
|
||||
allowEmpty
|
||||
providerPlaceholder={t("compaction.providerPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 border-b py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Archive className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t("compaction.threshold")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("compaction.thresholdHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input type="number" value={compThreshold} onChange={(e) => setCompThreshold(e.target.value)} placeholder="200" min={1} className="w-24 shrink-0 text-base md:text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 border-b py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-blue-500" />
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t("compaction.keepRecent")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("compaction.keepRecentHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input type="number" value={compKeepRecent} onChange={(e) => setCompKeepRecent(e.target.value)} placeholder="40" min={1} className="w-24 shrink-0 text-base md:text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 border-b py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Hash className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t("compaction.maxTokens")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("compaction.maxTokensHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input type="number" value={compMaxTokens} onChange={(e) => setCompMaxTokens(e.target.value)} placeholder="4096" min={256} className="w-24 shrink-0 text-base md:text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-orange-200 bg-orange-50 px-3 py-2 mt-4 text-xs text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("compaction.info")}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Curated 1536-dimension embedding models per provider type. */
|
||||
export const EMBEDDING_MODELS: Record<string, { id: string; name: string }[]> = {
|
||||
openai_compat: [
|
||||
{ id: "text-embedding-3-small", name: "text-embedding-3-small (1536d)" },
|
||||
{ id: "text-embedding-3-large", name: "text-embedding-3-large (3072d → 1536 via dimensions)" },
|
||||
{ id: "text-embedding-ada-002", name: "text-embedding-ada-002 (1536d)" },
|
||||
],
|
||||
openrouter: [
|
||||
{ id: "openai/text-embedding-3-small", name: "openai/text-embedding-3-small (1536d)" },
|
||||
{ id: "openai/text-embedding-3-large", name: "openai/text-embedding-3-large (3072d → 1536)" },
|
||||
{ id: "openai/text-embedding-ada-002", name: "openai/text-embedding-ada-002 (1536d)" },
|
||||
],
|
||||
gemini_native: [
|
||||
{ id: "gemini-embedding-001", name: "gemini-embedding-001 (3072d → 1536 via dimensions)" },
|
||||
],
|
||||
mistral: [
|
||||
{ id: "codestral-embed", name: "codestral-embed (1536d default)" },
|
||||
],
|
||||
dashscope: [
|
||||
{ id: "text-embedding-v3", name: "text-embedding-v3 (1536 via dimensions)" },
|
||||
],
|
||||
cohere: [
|
||||
{ id: "embed-v4", name: "embed-v4 (1536d native)" },
|
||||
],
|
||||
};
|
||||
|
||||
export const DEFAULT_EMBEDDING_MODELS: { id: string; name: string }[] = [];
|
||||
|
||||
export interface InitState {
|
||||
embProvider: string;
|
||||
embModel: string;
|
||||
embMaxChunkLen: string;
|
||||
embChunkOverlap: string;
|
||||
toolStatus: boolean;
|
||||
blockReply: boolean;
|
||||
intentClassify: boolean;
|
||||
compProvider: string;
|
||||
compModel: string;
|
||||
compThreshold: string;
|
||||
compKeepRecent: string;
|
||||
compMaxTokens: string;
|
||||
kgProvider: string;
|
||||
kgModel: string;
|
||||
kgMinConfidence: string;
|
||||
}
|
||||
|
||||
export const DEFAULTS: InitState = {
|
||||
embProvider: "", embModel: "",
|
||||
embMaxChunkLen: "", embChunkOverlap: "",
|
||||
toolStatus: true, blockReply: false, intentClassify: true,
|
||||
compProvider: "", compModel: "",
|
||||
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
|
||||
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
|
||||
};
|
||||
|
||||
export function parseBool(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined) return fallback;
|
||||
return v !== "false" && v !== "0";
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Brain, Info, Loader2, AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
|
||||
interface EmbVerifyResult {
|
||||
valid: boolean;
|
||||
dimensions?: number;
|
||||
dimension_mismatch?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SystemSettingsEmbeddingCardProps {
|
||||
embProvider: string;
|
||||
setEmbProvider: (v: string) => void;
|
||||
embModel: string;
|
||||
setEmbModel: (v: string) => void;
|
||||
embMaxChunkLen: string;
|
||||
setEmbMaxChunkLen: (v: string) => void;
|
||||
embChunkOverlap: string;
|
||||
setEmbChunkOverlap: (v: string) => void;
|
||||
extraModels: { id: string; name: string }[];
|
||||
onVerify: () => void;
|
||||
verifying: boolean;
|
||||
verifyResult: EmbVerifyResult | null;
|
||||
canVerify: boolean;
|
||||
}
|
||||
|
||||
export function SystemSettingsEmbeddingCard({
|
||||
embProvider, setEmbProvider,
|
||||
embModel, setEmbModel,
|
||||
embMaxChunkLen, setEmbMaxChunkLen,
|
||||
embChunkOverlap, setEmbChunkOverlap,
|
||||
extraModels, onVerify, verifying, verifyResult, canVerify,
|
||||
}: SystemSettingsEmbeddingCardProps) {
|
||||
const { t } = useTranslation("system-settings");
|
||||
|
||||
return (
|
||||
<Card className="border-blue-200 dark:border-blue-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Brain className="h-4 w-4 text-blue-500" />
|
||||
{t("embedding.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("embedding.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p>{t("embedding.importance")}</p>
|
||||
<p className="opacity-75">{t("embedding.supportedProviders")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProviderModelSelect
|
||||
provider={embProvider}
|
||||
onProviderChange={(v) => { setEmbProvider(v); setEmbModel(""); }}
|
||||
model={embModel}
|
||||
onModelChange={setEmbModel}
|
||||
allowEmpty
|
||||
showVerify={false}
|
||||
extraModels={extraModels}
|
||||
modelFilter="embed"
|
||||
providerLabel={t("embedding.provider")}
|
||||
modelLabel={t("embedding.model")}
|
||||
providerTip={t("embedding.providerTip")}
|
||||
modelTip={t("embedding.modelTip")}
|
||||
providerPlaceholder={t("embedding.providerPlaceholder")}
|
||||
modelPlaceholder={t("embedding.modelPlaceholder")}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="outline" size="sm" disabled={!canVerify || verifying} onClick={onVerify}>
|
||||
{verifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{t("embedding.verify")}
|
||||
</Button>
|
||||
{verifyResult && (
|
||||
<span className={`flex items-center gap-1 text-xs ${
|
||||
verifyResult.valid
|
||||
? verifyResult.dimension_mismatch ? "text-amber-600 dark:text-amber-400" : "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-destructive"
|
||||
}`}>
|
||||
{verifyResult.valid ? (
|
||||
<>
|
||||
{verifyResult.dimension_mismatch ? <AlertTriangle className="h-3.5 w-3.5" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
{verifyResult.dimension_mismatch
|
||||
? t("embedding.dimensionsMismatch", { count: verifyResult.dimensions })
|
||||
: t("embedding.dimensions", { count: verifyResult.dimensions })}
|
||||
</>
|
||||
) : (
|
||||
<><XCircle className="h-3.5 w-3.5" />{verifyResult.error || t("embedding.verifyFailed")}</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="embMaxChunkLen" className="text-xs">{t("embedding.maxChunkLen")}</Label>
|
||||
<Input id="embMaxChunkLen" type="number" placeholder="1000" value={embMaxChunkLen} onChange={(e) => setEmbMaxChunkLen(e.target.value)} className="text-base md:text-sm" />
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.maxChunkLenHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="embChunkOverlap" className="text-xs">{t("embedding.chunkOverlap")}</Label>
|
||||
<Input id="embChunkOverlap" type="number" placeholder="200" value={embChunkOverlap} onChange={(e) => setEmbChunkOverlap(e.target.value)} className="text-base md:text-sm" />
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.chunkOverlapHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink,
|
||||
Brain, Eye, MessageSquareText, Archive, Clock, Hash, CheckCircle2, XCircle, Network,
|
||||
} from "lucide-react";
|
||||
import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -19,80 +14,16 @@ import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
|
||||
// Curated 1536-dimension embedding models per provider type.
|
||||
// Verified 1536d embedding models per provider type.
|
||||
// Only providers with confirmed 1536-dimension output are listed.
|
||||
const EMBEDDING_MODELS: Record<string, { id: string; name: string }[]> = {
|
||||
// OpenAI — native 1536d
|
||||
openai_compat: [
|
||||
{ id: "text-embedding-3-small", name: "text-embedding-3-small (1536d)" },
|
||||
{ id: "text-embedding-3-large", name: "text-embedding-3-large (3072d → 1536 via dimensions)" },
|
||||
{ id: "text-embedding-ada-002", name: "text-embedding-ada-002 (1536d)" },
|
||||
],
|
||||
// OpenRouter — proxied OpenAI models
|
||||
openrouter: [
|
||||
{ id: "openai/text-embedding-3-small", name: "openai/text-embedding-3-small (1536d)" },
|
||||
{ id: "openai/text-embedding-3-large", name: "openai/text-embedding-3-large (3072d → 1536)" },
|
||||
{ id: "openai/text-embedding-ada-002", name: "openai/text-embedding-ada-002 (1536d)" },
|
||||
],
|
||||
// Gemini — gemini-embedding-001 (3072d native, truncate to 1536 via dimensions param)
|
||||
gemini_native: [
|
||||
{ id: "gemini-embedding-001", name: "gemini-embedding-001 (3072d → 1536 via dimensions)" },
|
||||
],
|
||||
// Mistral — codestral-embed defaults to 1536d (MRL)
|
||||
mistral: [
|
||||
{ id: "codestral-embed", name: "codestral-embed (1536d default)" },
|
||||
],
|
||||
// DashScope/Qwen — text-embedding-v3 (custom dimensions support)
|
||||
dashscope: [
|
||||
{ id: "text-embedding-v3", name: "text-embedding-v3 (1536 via dimensions)" },
|
||||
],
|
||||
// Cohere — embed-v4 native 1536d
|
||||
cohere: [
|
||||
{ id: "embed-v4", name: "embed-v4 (1536d native)" },
|
||||
],
|
||||
};
|
||||
// Fallback for unlisted provider types — no curated models
|
||||
const DEFAULT_EMBEDDING_MODELS: { id: string; name: string }[] = [];
|
||||
import { EMBEDDING_MODELS, DEFAULT_EMBEDDING_MODELS, DEFAULTS, parseBool, type InitState } from "./system-settings-constants";
|
||||
import { SystemSettingsEmbeddingCard } from "./system-settings-embedding-card";
|
||||
import { SystemSettingsCompactionCard } from "./system-settings-compaction-card";
|
||||
import { Eye, MessageSquareText, Brain } from "lucide-react";
|
||||
|
||||
interface SystemSettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface InitState {
|
||||
embProvider: string;
|
||||
embModel: string;
|
||||
embMaxChunkLen: string;
|
||||
embChunkOverlap: string;
|
||||
toolStatus: boolean;
|
||||
blockReply: boolean;
|
||||
intentClassify: boolean;
|
||||
compProvider: string;
|
||||
compModel: string;
|
||||
compThreshold: string;
|
||||
compKeepRecent: string;
|
||||
compMaxTokens: string;
|
||||
kgProvider: string;
|
||||
kgModel: string;
|
||||
kgMinConfidence: string;
|
||||
}
|
||||
|
||||
const DEFAULTS: InitState = {
|
||||
embProvider: "", embModel: "",
|
||||
embMaxChunkLen: "", embChunkOverlap: "",
|
||||
toolStatus: true, blockReply: false, intentClassify: true,
|
||||
compProvider: "", compModel: "",
|
||||
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
|
||||
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
|
||||
};
|
||||
|
||||
function parseBool(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined) return fallback;
|
||||
return v !== "false" && v !== "0";
|
||||
}
|
||||
|
||||
export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalProps) {
|
||||
const { t } = useTranslation("system-settings");
|
||||
const http = useHttp();
|
||||
@@ -114,7 +45,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
const [blockReply, setBlockReply] = useState(false);
|
||||
const [intentClassify, setIntentClassify] = useState(true);
|
||||
|
||||
// Pending Compaction
|
||||
// Compaction
|
||||
const [compProvider, setCompProvider] = useState("");
|
||||
const [compModel, setCompModel] = useState("");
|
||||
const [compThreshold, setCompThreshold] = useState("");
|
||||
@@ -131,38 +62,21 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
kgSettings?: { extraction_provider?: string; extraction_model?: string; min_confidence?: number },
|
||||
) => {
|
||||
const s: InitState = {
|
||||
embProvider: configs["embedding.provider"] ?? "",
|
||||
embModel: configs["embedding.model"] ?? "",
|
||||
embMaxChunkLen: configs["embedding.max_chunk_len"] ?? "",
|
||||
embChunkOverlap: configs["embedding.chunk_overlap"] ?? "",
|
||||
toolStatus: parseBool(configs["gateway.tool_status"], true),
|
||||
blockReply: parseBool(configs["gateway.block_reply"], false),
|
||||
embProvider: configs["embedding.provider"] ?? "", embModel: configs["embedding.model"] ?? "",
|
||||
embMaxChunkLen: configs["embedding.max_chunk_len"] ?? "", embChunkOverlap: configs["embedding.chunk_overlap"] ?? "",
|
||||
toolStatus: parseBool(configs["gateway.tool_status"], true), blockReply: parseBool(configs["gateway.block_reply"], false),
|
||||
intentClassify: parseBool(configs["gateway.intent_classify"], true),
|
||||
compProvider: configs["compaction.provider"] ?? "",
|
||||
compModel: configs["compaction.model"] ?? "",
|
||||
compThreshold: configs["compaction.threshold"] ?? "",
|
||||
compKeepRecent: configs["compaction.keep_recent"] ?? "",
|
||||
compProvider: configs["compaction.provider"] ?? "", compModel: configs["compaction.model"] ?? "",
|
||||
compThreshold: configs["compaction.threshold"] ?? "", compKeepRecent: configs["compaction.keep_recent"] ?? "",
|
||||
compMaxTokens: configs["compaction.max_tokens"] ?? "",
|
||||
kgProvider: kgSettings?.extraction_provider ?? "",
|
||||
kgModel: kgSettings?.extraction_model ?? "",
|
||||
kgProvider: kgSettings?.extraction_provider ?? "", kgModel: kgSettings?.extraction_model ?? "",
|
||||
kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75),
|
||||
};
|
||||
setInit(s);
|
||||
setEmbProvider(s.embProvider);
|
||||
setEmbModel(s.embModel);
|
||||
setEmbMaxChunkLen(s.embMaxChunkLen);
|
||||
setEmbChunkOverlap(s.embChunkOverlap);
|
||||
setToolStatus(s.toolStatus);
|
||||
setBlockReply(s.blockReply);
|
||||
setIntentClassify(s.intentClassify);
|
||||
setCompProvider(s.compProvider);
|
||||
setCompModel(s.compModel);
|
||||
setCompThreshold(s.compThreshold);
|
||||
setCompKeepRecent(s.compKeepRecent);
|
||||
setCompMaxTokens(s.compMaxTokens);
|
||||
setKgProvider(s.kgProvider);
|
||||
setKgModel(s.kgModel);
|
||||
setKgMinConfidence(s.kgMinConfidence);
|
||||
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
|
||||
setToolStatus(s.toolStatus); setBlockReply(s.blockReply); setIntentClassify(s.intentClassify);
|
||||
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
|
||||
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
|
||||
resetEmb();
|
||||
}, [resetEmb]);
|
||||
|
||||
@@ -185,17 +99,8 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
const embChanged = embProvider !== init.embProvider || embModel !== init.embModel;
|
||||
const embVerified = embResult?.valid === true;
|
||||
const saveDisabled = saving || (embChanged && !embVerified);
|
||||
|
||||
const selectedEmbProviderData = providers.find((p) => p.name === embProvider);
|
||||
const embExtraModels = selectedEmbProviderData
|
||||
? (EMBEDDING_MODELS[selectedEmbProviderData.provider_type] ?? DEFAULT_EMBEDDING_MODELS)
|
||||
: DEFAULT_EMBEDDING_MODELS;
|
||||
|
||||
const handleVerifyEmb = () => {
|
||||
if (!selectedEmbProviderData) return;
|
||||
// Always request 1536 dims — pgvector schema requires vector(1536).
|
||||
verifyEmbedding(selectedEmbProviderData.id, embModel.trim() || undefined, 1536);
|
||||
};
|
||||
const embExtraModels = selectedEmbProviderData ? (EMBEDDING_MODELS[selectedEmbProviderData.provider_type] ?? DEFAULT_EMBEDDING_MODELS) : DEFAULT_EMBEDDING_MODELS;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
@@ -213,64 +118,22 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
if (compThreshold !== init.compThreshold) updates["compaction.threshold"] = compThreshold;
|
||||
if (compKeepRecent !== init.compKeepRecent) updates["compaction.keep_recent"] = compKeepRecent;
|
||||
if (compMaxTokens !== init.compMaxTokens) updates["compaction.max_tokens"] = compMaxTokens;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
await http.put(`/v1/system-configs/${key}`, { value });
|
||||
}
|
||||
|
||||
// KG extraction — save via builtin tools API
|
||||
for (const [key, value] of Object.entries(updates)) await http.put(`/v1/system-configs/${key}`, { value });
|
||||
const kgChanged = kgProvider !== init.kgProvider || kgModel !== init.kgModel || kgMinConfidence !== init.kgMinConfidence;
|
||||
if (kgChanged) {
|
||||
await http.put("/v1/tools/builtin/knowledge_graph_search", {
|
||||
settings: {
|
||||
extraction_provider: kgProvider,
|
||||
extraction_model: kgModel,
|
||||
min_confidence: Number(kgMinConfidence) || 0.75,
|
||||
extract_on_memory_write: !!(kgProvider && kgModel),
|
||||
},
|
||||
settings: { extraction_provider: kgProvider, extraction_model: kgModel, min_confidence: Number(kgMinConfidence) || 0.75, extract_on_memory_write: !!(kgProvider && kgModel) },
|
||||
});
|
||||
}
|
||||
toast.success(t("saved"));
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
toast.success(t("saved")); onOpenChange(false);
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : t("saveFailed")); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// UX Behavior switch items — reuses FeatureSwitchGroup pattern from config page
|
||||
const uxItems: FeatureSwitchItem[] = [
|
||||
{
|
||||
icon: Eye,
|
||||
iconClass: "text-blue-500",
|
||||
label: t("ux.toolStatus"),
|
||||
hint: t("ux.toolStatusHint"),
|
||||
checked: toolStatus,
|
||||
onCheckedChange: setToolStatus,
|
||||
infoWhenOn: t("ux.toolStatusInfo"),
|
||||
infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300",
|
||||
},
|
||||
{
|
||||
icon: MessageSquareText,
|
||||
iconClass: "text-emerald-500",
|
||||
label: t("ux.blockReply"),
|
||||
hint: t("ux.blockReplyHint"),
|
||||
checked: blockReply,
|
||||
onCheckedChange: setBlockReply,
|
||||
infoWhenOn: t("ux.blockReplyInfo"),
|
||||
infoClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300",
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
iconClass: "text-orange-500",
|
||||
label: t("ux.intentClassify"),
|
||||
hint: t("ux.intentClassifyHint"),
|
||||
checked: intentClassify,
|
||||
onCheckedChange: setIntentClassify,
|
||||
infoWhenOn: t("ux.intentClassifyInfo"),
|
||||
infoClass: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300",
|
||||
},
|
||||
{ icon: Eye, iconClass: "text-blue-500", label: t("ux.toolStatus"), hint: t("ux.toolStatusHint"), checked: toolStatus, onCheckedChange: setToolStatus, infoWhenOn: t("ux.toolStatusInfo"), infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300" },
|
||||
{ icon: MessageSquareText, iconClass: "text-emerald-500", label: t("ux.blockReply"), hint: t("ux.blockReplyHint"), checked: blockReply, onCheckedChange: setBlockReply, infoWhenOn: t("ux.blockReplyInfo"), infoClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300" },
|
||||
{ icon: Brain, iconClass: "text-orange-500", label: t("ux.intentClassify"), hint: t("ux.intentClassifyHint"), checked: intentClassify, onCheckedChange: setIntentClassify, infoWhenOn: t("ux.intentClassifyInfo"), infoClass: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -278,231 +141,53 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
<DialogContent className="flex max-h-[90vh] w-[95vw] flex-col sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
{t("title")}
|
||||
<Settings2 className="h-5 w-5" />{t("title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6">
|
||||
{/* ── Embedding ── */}
|
||||
<Card className="border-blue-200 dark:border-blue-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Brain className="h-4 w-4 text-blue-500" />
|
||||
{t("embedding.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("embedding.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p>{t("embedding.importance")}</p>
|
||||
<p className="opacity-75">{t("embedding.supportedProviders")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SystemSettingsEmbeddingCard
|
||||
embProvider={embProvider} setEmbProvider={setEmbProvider}
|
||||
embModel={embModel} setEmbModel={setEmbModel}
|
||||
embMaxChunkLen={embMaxChunkLen} setEmbMaxChunkLen={setEmbMaxChunkLen}
|
||||
embChunkOverlap={embChunkOverlap} setEmbChunkOverlap={setEmbChunkOverlap}
|
||||
extraModels={embExtraModels}
|
||||
onVerify={() => { if (selectedEmbProviderData) verifyEmbedding(selectedEmbProviderData.id, embModel.trim() || undefined, 1536); }}
|
||||
verifying={embVerifying} verifyResult={embResult}
|
||||
canVerify={!!selectedEmbProviderData && !!embModel.trim()}
|
||||
/>
|
||||
|
||||
<ProviderModelSelect
|
||||
provider={embProvider}
|
||||
onProviderChange={(v) => { setEmbProvider(v); setEmbModel(""); }}
|
||||
model={embModel}
|
||||
onModelChange={setEmbModel}
|
||||
allowEmpty
|
||||
showVerify={false}
|
||||
extraModels={embExtraModels}
|
||||
modelFilter="embed"
|
||||
providerLabel={t("embedding.provider")}
|
||||
modelLabel={t("embedding.model")}
|
||||
providerTip={t("embedding.providerTip")}
|
||||
modelTip={t("embedding.modelTip")}
|
||||
providerPlaceholder={t("embedding.providerPlaceholder")}
|
||||
modelPlaceholder={t("embedding.modelPlaceholder")}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!selectedEmbProviderData || !embModel.trim() || embVerifying}
|
||||
onClick={handleVerifyEmb}
|
||||
>
|
||||
{embVerifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{t("embedding.verify")}
|
||||
</Button>
|
||||
{embResult && (
|
||||
<span className={`flex items-center gap-1 text-xs ${
|
||||
embResult.valid
|
||||
? embResult.dimension_mismatch
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-destructive"
|
||||
}`}>
|
||||
{embResult.valid ? (
|
||||
<>
|
||||
{embResult.dimension_mismatch ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{embResult.dimension_mismatch
|
||||
? t("embedding.dimensionsMismatch", { count: embResult.dimensions })
|
||||
: t("embedding.dimensions", { count: embResult.dimensions })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{embResult.error || t("embedding.verifyFailed")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="embMaxChunkLen" className="text-xs">{t("embedding.maxChunkLen")}</Label>
|
||||
<Input id="embMaxChunkLen" type="number" placeholder="1000" value={embMaxChunkLen} onChange={(e) => setEmbMaxChunkLen(e.target.value)} className="text-base md:text-sm" />
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.maxChunkLenHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="embChunkOverlap" className="text-xs">{t("embedding.chunkOverlap")}</Label>
|
||||
<Input id="embChunkOverlap" type="number" placeholder="200" value={embChunkOverlap} onChange={(e) => setEmbChunkOverlap(e.target.value)} className="text-base md:text-sm" />
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.chunkOverlapHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Knowledge Graph Extraction ── */}
|
||||
{/* Knowledge Graph */}
|
||||
<Card className="border-violet-200 dark:border-violet-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Network className="h-4 w-4 text-violet-500" />
|
||||
{t("kg.title")}
|
||||
</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Network className="h-4 w-4 text-violet-500" />{t("kg.title")}</CardTitle>
|
||||
<CardDescription>{t("kg.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<ProviderModelSelect
|
||||
provider={kgProvider}
|
||||
onProviderChange={(v) => { setKgProvider(v); setKgModel(""); }}
|
||||
model={kgModel}
|
||||
onModelChange={setKgModel}
|
||||
allowEmpty
|
||||
providerLabel={t("kg.provider")}
|
||||
modelLabel={t("kg.model")}
|
||||
providerTip={t("kg.providerTip")}
|
||||
modelTip={t("kg.modelTip")}
|
||||
providerPlaceholder={t("kg.providerPlaceholder")}
|
||||
modelPlaceholder={t("kg.modelPlaceholder")}
|
||||
/>
|
||||
|
||||
<ProviderModelSelect provider={kgProvider} onProviderChange={(v) => { setKgProvider(v); setKgModel(""); }} model={kgModel} onModelChange={setKgModel} allowEmpty providerLabel={t("kg.provider")} modelLabel={t("kg.model")} providerTip={t("kg.providerTip")} modelTip={t("kg.modelTip")} providerPlaceholder={t("kg.providerPlaceholder")} modelPlaceholder={t("kg.modelPlaceholder")} />
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="kgMinConf" className="text-xs">{t("kg.minConfidence")}</Label>
|
||||
<Input
|
||||
id="kgMinConf"
|
||||
type="number"
|
||||
min={0} max={1} step={0.05}
|
||||
placeholder="0.75"
|
||||
value={kgMinConfidence}
|
||||
onChange={(e) => setKgMinConfidence(e.target.value)}
|
||||
className="max-w-[120px] text-base md:text-sm"
|
||||
/>
|
||||
<Input id="kgMinConf" type="number" min={0} max={1} step={0.05} placeholder="0.75" value={kgMinConfidence} onChange={(e) => setKgMinConfidence(e.target.value)} className="max-w-[120px] text-base md:text-sm" />
|
||||
<p className="text-xs text-muted-foreground">{t("kg.minConfidenceHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-violet-200 bg-violet-50 px-3 py-2 text-xs text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("kg.info")}</span>
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{t("kg.info")}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── UX Behavior — uses FeatureSwitchGroup (same as config page) ── */}
|
||||
<FeatureSwitchGroup
|
||||
title={t("ux.title")}
|
||||
description={t("ux.description")}
|
||||
items={uxItems}
|
||||
<FeatureSwitchGroup title={t("ux.title")} description={t("ux.description")} items={uxItems} />
|
||||
|
||||
<SystemSettingsCompactionCard
|
||||
compProvider={compProvider} setCompProvider={setCompProvider}
|
||||
compModel={compModel} setCompModel={setCompModel}
|
||||
compThreshold={compThreshold} setCompThreshold={setCompThreshold}
|
||||
compKeepRecent={compKeepRecent} setCompKeepRecent={setCompKeepRecent}
|
||||
compMaxTokens={compMaxTokens} setCompMaxTokens={setCompMaxTokens}
|
||||
/>
|
||||
|
||||
{/* ── Pending Compaction ── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("compaction.title")}</CardTitle>
|
||||
<CardDescription>{t("compaction.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0 pt-0">
|
||||
<div className="border-b pb-4">
|
||||
<ProviderModelSelect
|
||||
provider={compProvider}
|
||||
onProviderChange={setCompProvider}
|
||||
model={compModel}
|
||||
onModelChange={setCompModel}
|
||||
allowEmpty
|
||||
providerPlaceholder={t("compaction.providerPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 border-b py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Archive className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t("compaction.threshold")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("compaction.thresholdHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number" value={compThreshold}
|
||||
onChange={(e) => setCompThreshold(e.target.value)}
|
||||
placeholder="200" min={1}
|
||||
className="w-24 shrink-0 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 border-b py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-blue-500" />
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t("compaction.keepRecent")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("compaction.keepRecentHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number" value={compKeepRecent}
|
||||
onChange={(e) => setCompKeepRecent(e.target.value)}
|
||||
placeholder="40" min={1}
|
||||
className="w-24 shrink-0 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 border-b py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Hash className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t("compaction.maxTokens")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("compaction.maxTokensHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number" value={compMaxTokens}
|
||||
onChange={(e) => setCompMaxTokens(e.target.value)}
|
||||
placeholder="4096" min={256}
|
||||
className="w-24 shrink-0 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-orange-200 bg-orange-50 px-3 py-2 mt-4 text-xs text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("compaction.info")}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -510,23 +195,15 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
<div className="flex flex-col gap-3 border-t pt-4 shrink-0">
|
||||
{embChanged && !embVerified && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
{t("embedding.verifyRequired")}
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />{t("embedding.verifyRequired")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link
|
||||
to="/config"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("moreConfig")}
|
||||
<Link to="/config" onClick={() => onOpenChange(false)} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ExternalLink className="h-3 w-3" />{t("moreConfig")}
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>{t("cancel")}</Button>
|
||||
<Button onClick={handleSave} disabled={saveDisabled}>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{saving ? t("saving") : t("save")}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* DnD wrapper components for the file tree: draggable items and droppable folders.
|
||||
* Uses @dnd-kit/core primitives.
|
||||
*/
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
|
||||
/** Draggable wrapper for tree items (files and folders). */
|
||||
export function DraggableItem({
|
||||
id,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id,
|
||||
disabled: !enabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...(enabled ? { ...listeners, ...attributes } : {})}
|
||||
className={isDragging ? "opacity-40" : ""}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Droppable wrapper for folder items. */
|
||||
export function DroppableFolder({
|
||||
id,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
children: (props: { isDropTarget: boolean }) => React.ReactNode;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id,
|
||||
disabled: !enabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
{children({ isDropTarget: isOver })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Root-level droppable zone — dropping here moves to root (""). */
|
||||
export function RootDropZone({ children }: { children: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "__root__" });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={`min-h-full ${isOver ? "bg-primary/5" : ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* File icon component for the file tree — maps file extensions to Lucide icons.
|
||||
*/
|
||||
import {
|
||||
FileText,
|
||||
FileCode2,
|
||||
File,
|
||||
FileImage,
|
||||
FileJson2,
|
||||
FileSpreadsheet,
|
||||
FileTerminal,
|
||||
FileArchive,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileCog,
|
||||
FileType,
|
||||
FileLock,
|
||||
} from "lucide-react";
|
||||
import { extOf, CODE_EXTENSIONS, IMAGE_EXTENSIONS } from "@/lib/file-helpers";
|
||||
|
||||
const cls = "h-4 w-4 shrink-0";
|
||||
|
||||
export function FileIcon({ name }: { name: string }) {
|
||||
const ext = extOf(name);
|
||||
if (ext === "md" || ext === "mdx") return <FileText className={`${cls} text-blue-500`} />;
|
||||
if (ext === "json" || ext === "json5") return <FileJson2 className={`${cls} text-yellow-600`} />;
|
||||
if (ext === "yaml" || ext === "yml" || ext === "toml") return <FileCog className={`${cls} text-orange-500`} />;
|
||||
if (ext === "csv") return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
||||
if (ext === "sh" || ext === "bash" || ext === "zsh") return <FileTerminal className={`${cls} text-lime-600`} />;
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={`${cls} text-emerald-500`} />;
|
||||
if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return <FileVideo className={`${cls} text-pink-500`} />;
|
||||
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return <FileAudio className={`${cls} text-orange-500`} />;
|
||||
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return <FileArchive className={`${cls} text-amber-600`} />;
|
||||
if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return <FileType className={`${cls} text-slate-500`} />;
|
||||
if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return <FileLock className={`${cls} text-red-500`} />;
|
||||
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className={`${cls} text-orange-500`} />;
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
@@ -1,102 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
FileCode2,
|
||||
File,
|
||||
FileImage,
|
||||
FileJson2,
|
||||
FileSpreadsheet,
|
||||
FileTerminal,
|
||||
FileArchive,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileCog,
|
||||
FileType,
|
||||
FileLock,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { extOf, CODE_EXTENSIONS, IMAGE_EXTENSIONS, formatSize, type TreeNode } from "@/lib/file-helpers";
|
||||
import { DndContext, DragOverlay } from "@dnd-kit/core";
|
||||
import { Folder, FolderOpen, ChevronRight, Loader2, Trash2 } from "lucide-react";
|
||||
import { formatSize, type TreeNode } from "@/lib/file-helpers";
|
||||
import { useTreeDnd } from "@/hooks/use-tree-dnd";
|
||||
import { DragPreview } from "@/components/shared/drag-preview";
|
||||
|
||||
const cls = "h-4 w-4 shrink-0";
|
||||
|
||||
function FileIcon({ name }: { name: string }) {
|
||||
const ext = extOf(name);
|
||||
if (ext === "md" || ext === "mdx") return <FileText className={`${cls} text-blue-500`} />;
|
||||
if (ext === "json" || ext === "json5") return <FileJson2 className={`${cls} text-yellow-600`} />;
|
||||
if (ext === "yaml" || ext === "yml" || ext === "toml") return <FileCog className={`${cls} text-orange-500`} />;
|
||||
if (ext === "csv") return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
||||
if (ext === "sh" || ext === "bash" || ext === "zsh") return <FileTerminal className={`${cls} text-lime-600`} />;
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={`${cls} text-emerald-500`} />;
|
||||
if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return <FileVideo className={`${cls} text-pink-500`} />;
|
||||
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return <FileAudio className={`${cls} text-orange-500`} />;
|
||||
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return <FileArchive className={`${cls} text-amber-600`} />;
|
||||
if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return <FileType className={`${cls} text-slate-500`} />;
|
||||
if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return <FileLock className={`${cls} text-red-500`} />;
|
||||
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className={`${cls} text-orange-500`} />;
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
/** Draggable wrapper for tree items (files and folders). */
|
||||
function DraggableItem({
|
||||
id,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id,
|
||||
disabled: !enabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...(enabled ? { ...listeners, ...attributes } : {})}
|
||||
className={isDragging ? "opacity-40" : ""}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Droppable wrapper for folder items. */
|
||||
function DroppableFolder({
|
||||
id,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
children: (props: { isDropTarget: boolean }) => React.ReactNode;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id,
|
||||
disabled: !enabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
{children({ isDropTarget: isOver })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { FileIcon } from "./file-tree-file-icon";
|
||||
import { DraggableItem, DroppableFolder, RootDropZone } from "./file-tree-dnd-wrappers";
|
||||
|
||||
export function TreeItem({
|
||||
node,
|
||||
@@ -346,13 +257,3 @@ export function FileTreePanel({
|
||||
);
|
||||
}
|
||||
|
||||
/** Root-level droppable zone — dropping here moves to root (""). */
|
||||
function RootDropZone({ children }: { children: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "__root__" });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={`min-h-full ${isOver ? "bg-primary/5" : ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Internal viewer panels used by FileContentBody:
|
||||
* - CodeViewer: syntax-highlighted code with copy button
|
||||
* - CsvViewer: tabular CSV display with copy button
|
||||
* - ImageViewer: blob-fetched image display
|
||||
* - UnsupportedFileViewer: fallback with download button
|
||||
*/
|
||||
import { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Copy, Download, FileQuestion, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { formatSize, sizeBadgeVariant } from "@/lib/file-helpers";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import go from "highlight.js/lib/languages/go";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import yaml from "highlight.js/lib/languages/yaml";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import sql from "highlight.js/lib/languages/sql";
|
||||
import rust from "highlight.js/lib/languages/rust";
|
||||
import ruby from "highlight.js/lib/languages/ruby";
|
||||
import java from "highlight.js/lib/languages/java";
|
||||
import c from "highlight.js/lib/languages/c";
|
||||
import cpp from "highlight.js/lib/languages/cpp";
|
||||
import lua from "highlight.js/lib/languages/lua";
|
||||
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("tsx", typescript);
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("jsx", javascript);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("go", go);
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("yaml", yaml);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("html", xml);
|
||||
hljs.registerLanguage("xml", xml);
|
||||
hljs.registerLanguage("sql", sql);
|
||||
hljs.registerLanguage("rust", rust);
|
||||
hljs.registerLanguage("ruby", ruby);
|
||||
hljs.registerLanguage("java", java);
|
||||
hljs.registerLanguage("c", c);
|
||||
hljs.registerLanguage("cpp", cpp);
|
||||
hljs.registerLanguage("lua", lua);
|
||||
|
||||
export function CodeViewer({ content, language }: { content: string; language: string }) {
|
||||
const { copied, copy } = useClipboard();
|
||||
const { t } = useTranslation("common");
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
|
||||
const highlighted = useMemo(() => {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
try {
|
||||
return hljs.highlight(content, { language }).value;
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
return null;
|
||||
}, [content, language]);
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-lg border border-border/60">
|
||||
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
<span>{language || "text"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(content)}
|
||||
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title={t("copy")}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-auto bg-muted/30 p-4 text-[13px] leading-relaxed hljs">
|
||||
{highlighted ? (
|
||||
<code
|
||||
ref={codeRef}
|
||||
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }}
|
||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||
/>
|
||||
) : (
|
||||
<code style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }}>
|
||||
{content}
|
||||
</code>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CsvViewer({ content }: { content: string }) {
|
||||
const { copied, copy } = useClipboard();
|
||||
const { t } = useTranslation("common");
|
||||
const rows = useMemo(() => {
|
||||
return content.split("\n").filter(Boolean).map((line) => {
|
||||
const cols: string[] = [];
|
||||
let cur = "";
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') { inQuote = !inQuote; continue; }
|
||||
if (ch === "," && !inQuote) { cols.push(cur.trim()); cur = ""; continue; }
|
||||
cur += ch;
|
||||
}
|
||||
cols.push(cur.trim());
|
||||
return cols;
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
const header = rows[0];
|
||||
if (!header || rows.length === 0) return <pre className="text-sm p-4">{content}</pre>;
|
||||
const body = rows.slice(1);
|
||||
|
||||
return (
|
||||
<div className="group relative rounded-lg border border-border/60 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase shrink-0">
|
||||
<span>{t("csvRows", { count: body.length })}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(content)}
|
||||
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title={t("copy")}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1 min-h-0">
|
||||
<table className="w-full text-[13px] border-collapse">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-muted/70">
|
||||
{header.map((col, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left text-xs font-semibold tracking-wide border-b border-border/60 whitespace-nowrap">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border/40 last:border-0 even:bg-muted/30 hover:bg-muted/50">
|
||||
{header.map((_, j) => (
|
||||
<td key={j} className="px-3 py-1.5 border-r border-border/30 last:border-r-0">
|
||||
{row[j] ?? ""}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise<Blob> }) {
|
||||
const { t } = useTranslation("common");
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
fetchBlob(path)
|
||||
.then((blob) => {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [path, fetchBlob]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !src) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t("failedToLoadImage")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<img
|
||||
src={src}
|
||||
alt={path.split("/").pop() ?? ""}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg border border-border/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UnsupportedFileViewer({
|
||||
path,
|
||||
size,
|
||||
onDownload,
|
||||
}: {
|
||||
path: string;
|
||||
size: number;
|
||||
onDownload?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("storage");
|
||||
const fileName = path.split("/").pop() ?? path;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<FileQuestion className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t("unsupportedFile")}</p>
|
||||
{onDownload && (
|
||||
<Button variant="outline" size="sm" onClick={onDownload}>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{fileName}
|
||||
<Badge variant={sizeBadgeVariant(size)} className="text-[10px] ml-1.5">
|
||||
{formatSize(size)}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,239 +1,26 @@
|
||||
import { useMemo, useRef, useState, useEffect } from "react";
|
||||
/**
|
||||
* FileContentBody and FileContentPanel — smart file viewers that route
|
||||
* to the correct viewer based on file type.
|
||||
*
|
||||
* Viewer panel implementations live in file-viewer-panels.tsx.
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Copy, Download, FileQuestion, Loader2 } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import go from "highlight.js/lib/languages/go";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import yaml from "highlight.js/lib/languages/yaml";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import sql from "highlight.js/lib/languages/sql";
|
||||
import rust from "highlight.js/lib/languages/rust";
|
||||
import ruby from "highlight.js/lib/languages/ruby";
|
||||
import java from "highlight.js/lib/languages/java";
|
||||
import c from "highlight.js/lib/languages/c";
|
||||
import cpp from "highlight.js/lib/languages/cpp";
|
||||
import lua from "highlight.js/lib/languages/lua";
|
||||
import {
|
||||
extOf,
|
||||
langFor,
|
||||
stripFrontmatter,
|
||||
formatSize,
|
||||
sizeBadgeVariant,
|
||||
isImageFile,
|
||||
isTextFile,
|
||||
CODE_EXTENSIONS,
|
||||
} from "@/lib/file-helpers";
|
||||
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("tsx", typescript);
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("jsx", javascript);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("go", go);
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("yaml", yaml);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("html", xml);
|
||||
hljs.registerLanguage("xml", xml);
|
||||
hljs.registerLanguage("sql", sql);
|
||||
hljs.registerLanguage("rust", rust);
|
||||
hljs.registerLanguage("ruby", ruby);
|
||||
hljs.registerLanguage("java", java);
|
||||
hljs.registerLanguage("c", c);
|
||||
hljs.registerLanguage("cpp", cpp);
|
||||
hljs.registerLanguage("lua", lua);
|
||||
|
||||
function CodeViewer({ content, language }: { content: string; language: string }) {
|
||||
const { copied, copy } = useClipboard();
|
||||
const { t } = useTranslation("common");
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
|
||||
const highlighted = useMemo(() => {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
try {
|
||||
return hljs.highlight(content, { language }).value;
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
return null;
|
||||
}, [content, language]);
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-lg border border-border/60">
|
||||
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
<span>{language || "text"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(content)}
|
||||
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title={t("copy")}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-auto bg-muted/30 p-4 text-[13px] leading-relaxed hljs">
|
||||
{highlighted ? (
|
||||
<code ref={codeRef} style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }} dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
) : (
|
||||
<code style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }}>{content}</code>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CsvViewer({ content }: { content: string }) {
|
||||
const { copied, copy } = useClipboard();
|
||||
const { t } = useTranslation("common");
|
||||
const rows = useMemo(() => {
|
||||
return content.split("\n").filter(Boolean).map((line) => {
|
||||
const cols: string[] = [];
|
||||
let cur = "";
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') { inQuote = !inQuote; continue; }
|
||||
if (ch === "," && !inQuote) { cols.push(cur.trim()); cur = ""; continue; }
|
||||
cur += ch;
|
||||
}
|
||||
cols.push(cur.trim());
|
||||
return cols;
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
const header = rows[0];
|
||||
if (!header || rows.length === 0) return <pre className="text-sm p-4">{content}</pre>;
|
||||
const body = rows.slice(1);
|
||||
|
||||
return (
|
||||
<div className="group relative rounded-lg border border-border/60 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase shrink-0">
|
||||
<span>{t("csvRows", { count: body.length })}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(content)}
|
||||
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title={t("copy")}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1 min-h-0">
|
||||
<table className="w-full text-[13px] border-collapse">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-muted/70">
|
||||
{header.map((col, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left text-xs font-semibold tracking-wide border-b border-border/60 whitespace-nowrap">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border/40 last:border-0 even:bg-muted/30 hover:bg-muted/50">
|
||||
{header.map((_, j) => (
|
||||
<td key={j} className="px-3 py-1.5 border-r border-border/30 last:border-r-0">
|
||||
{row[j] ?? ""}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise<Blob> }) {
|
||||
const { t } = useTranslation("common");
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
fetchBlob(path)
|
||||
.then((blob) => {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [path, fetchBlob]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !src) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t("failedToLoadImage")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<img
|
||||
src={src}
|
||||
alt={path.split("/").pop() ?? ""}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg border border-border/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnsupportedFileViewer({
|
||||
path,
|
||||
size,
|
||||
onDownload,
|
||||
}: {
|
||||
path: string;
|
||||
size: number;
|
||||
onDownload?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("storage");
|
||||
const fileName = path.split("/").pop() ?? path;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<FileQuestion className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t("unsupportedFile")}</p>
|
||||
{onDownload && (
|
||||
<Button variant="outline" size="sm" onClick={onDownload}>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{fileName}
|
||||
<Badge variant={sizeBadgeVariant(size)} className="text-[10px] ml-1.5">
|
||||
{formatSize(size)}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import {
|
||||
CodeViewer,
|
||||
CsvViewer,
|
||||
ImageViewer,
|
||||
UnsupportedFileViewer,
|
||||
} from "./file-viewer-panels";
|
||||
|
||||
export function FileContentBody({
|
||||
path,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* CachedMarkdownImg — image component for MarkdownRenderer that:
|
||||
* - Resolves /v1/files/ URLs via useMediaUrl cache
|
||||
* - Opens lightbox on click
|
||||
* - Shows a download button on hover
|
||||
*/
|
||||
import { Download } from "lucide-react";
|
||||
import { toFileUrl, toDownloadUrl } from "@/lib/file-helpers";
|
||||
import { useMediaUrl } from "@/hooks/use-media-url";
|
||||
|
||||
function fileNameFromHref(href: string): string {
|
||||
const path = href.split("?")[0] ?? href;
|
||||
const segments = path.split("/");
|
||||
return segments[segments.length - 1] ?? "file";
|
||||
}
|
||||
|
||||
export function CachedMarkdownImg({
|
||||
src,
|
||||
alt,
|
||||
openLightbox,
|
||||
...props
|
||||
}: {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
openLightbox: (src: string, alt: string) => void;
|
||||
[key: string]: unknown;
|
||||
}) {
|
||||
const isFileLink = src
|
||||
? src.startsWith("/v1/files/") || src.includes("/v1/files/")
|
||||
: false;
|
||||
const resolvedSrc = isFileLink ? toFileUrl(src!) : src;
|
||||
const cachedSrc = useMediaUrl(resolvedSrc);
|
||||
const displayName = alt || fileNameFromHref(src ?? "");
|
||||
|
||||
return (
|
||||
<span className="group/img relative inline-block overflow-hidden rounded-lg border shadow-sm">
|
||||
<img
|
||||
src={cachedSrc}
|
||||
alt={alt ?? "image"}
|
||||
className="block max-w-sm cursor-pointer hover:opacity-90 transition-opacity"
|
||||
loading="lazy"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image");
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{resolvedSrc && (
|
||||
<a
|
||||
href={toDownloadUrl(resolvedSrc)}
|
||||
download={displayName}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-4.5 w-4.5" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* CodeBlock component for MarkdownRenderer — syntax-highlighted code block
|
||||
* with a copy button and language label in the header bar.
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
|
||||
export function CodeBlock({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { copied, copy } = useClipboard();
|
||||
const { t } = useTranslation("common");
|
||||
const text = String(children).replace(/\n$/, "");
|
||||
const lang = className?.replace("language-", "") ?? "";
|
||||
|
||||
return (
|
||||
<div className="not-prose group relative my-3 overflow-hidden rounded-lg border border-border/60">
|
||||
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
<span>{lang || "code"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(text)}
|
||||
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title={t("copyCode")}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto bg-muted/30 p-3 text-[13px] leading-normal text-foreground whitespace-pre">
|
||||
<code
|
||||
className={className}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace",
|
||||
wordWrap: "normal",
|
||||
overflowWrap: "normal",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
@@ -7,10 +6,8 @@ import rehypeHighlight from "rehype-highlight";
|
||||
// Stable plugin arrays — avoids new references on every render
|
||||
const remarkPlugins = [remarkGfm];
|
||||
const rehypePlugins = [rehypeHighlight];
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { toFileUrl, toDownloadUrl } from "@/lib/file-helpers";
|
||||
import { useMediaUrl } from "@/hooks/use-media-url";
|
||||
import { Check, Copy, Download, FileText } from "lucide-react";
|
||||
import { Download, FileText } from "lucide-react";
|
||||
import { ImageLightbox } from "./image-lightbox";
|
||||
import { useChatImageGallery } from "@/components/chat/chat-image-gallery-context";
|
||||
import {
|
||||
@@ -19,38 +16,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function CodeBlock({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { copied, copy } = useClipboard();
|
||||
const { t } = useTranslation("common");
|
||||
const text = String(children).replace(/\n$/, "");
|
||||
const lang = className?.replace("language-", "") ?? "";
|
||||
|
||||
return (
|
||||
<div className="not-prose group relative my-3 overflow-hidden rounded-lg border border-border/60">
|
||||
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
<span>{lang || "code"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(text)}
|
||||
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title={t("copyCode")}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto bg-muted/30 p-3 text-[13px] leading-normal text-foreground whitespace-pre">
|
||||
<code className={className} style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace", wordWrap: "normal", overflowWrap: "normal" }}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { CodeBlock } from "./markdown-code-block";
|
||||
import { CachedMarkdownImg } from "./markdown-cached-img";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
@@ -87,43 +54,6 @@ function fileNameFromHref(href: string): string {
|
||||
return segments[segments.length - 1] ?? "file";
|
||||
}
|
||||
|
||||
/** Extracted from useMemo components so useMediaUrl hook is valid. */
|
||||
function CachedMarkdownImg({ src, alt, openLightbox, ...props }: {
|
||||
src?: string; alt?: string;
|
||||
openLightbox: (src: string, alt: string) => void;
|
||||
[key: string]: unknown;
|
||||
}) {
|
||||
const resolvedSrc = isFileLink(src) ? toFileUrl(src!) : src;
|
||||
const cachedSrc = useMediaUrl(resolvedSrc);
|
||||
const displayName = alt || fileNameFromHref(src ?? "");
|
||||
return (
|
||||
<span className="group/img relative inline-block overflow-hidden rounded-lg border shadow-sm">
|
||||
<img
|
||||
src={cachedSrc}
|
||||
alt={alt ?? "image"}
|
||||
className="block max-w-sm cursor-pointer hover:opacity-90 transition-opacity"
|
||||
loading="lazy"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image");
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{resolvedSrc && (
|
||||
<a
|
||||
href={toDownloadUrl(resolvedSrc)}
|
||||
download={displayName}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-4.5 w-4.5" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
const gallery = useChatImageGallery();
|
||||
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface HeartbeatAdvancedPanelProps {
|
||||
ackMaxChars: number;
|
||||
setAckMaxChars: (v: number) => void;
|
||||
maxRetries: number;
|
||||
setMaxRetries: (v: number) => void;
|
||||
isolatedSession: boolean;
|
||||
setIsolatedSession: (v: boolean) => void;
|
||||
lightContext: boolean;
|
||||
setLightContext: (v: boolean) => void;
|
||||
}
|
||||
|
||||
/** Collapsible advanced settings panel for the heartbeat config dialog. */
|
||||
export function HeartbeatAdvancedPanel({
|
||||
ackMaxChars, setAckMaxChars,
|
||||
maxRetries, setMaxRetries,
|
||||
isolatedSession, setIsolatedSession,
|
||||
lightContext, setLightContext,
|
||||
}: HeartbeatAdvancedPanelProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>{t("heartbeat.advancedSettings")}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="border-t px-3 py-3 space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-ack" className="text-xs">{t("heartbeat.ackMaxChars")}</Label>
|
||||
<Input
|
||||
id="hb-ack"
|
||||
type="number"
|
||||
min={0}
|
||||
value={ackMaxChars}
|
||||
onChange={(e) => setAckMaxChars(Number(e.target.value))}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.ackMaxCharsHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-retries" className="text-xs">{t("heartbeat.maxRetries")}</Label>
|
||||
<Input
|
||||
id="hb-retries"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={maxRetries}
|
||||
onChange={(e) => setMaxRetries(Number(e.target.value))}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.maxRetriesHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium">{t("heartbeat.isolatedSession")}</span>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.isolatedSessionHint")}</p>
|
||||
</div>
|
||||
<Switch checked={isolatedSession} onCheckedChange={setIsolatedSession} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium">{t("heartbeat.lightContext")}</span>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.lightContextHint")}</p>
|
||||
</div>
|
||||
<Switch checked={lightContext} onCheckedChange={setLightContext} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Play, Loader2, ChevronDown, Heart, Clock, Send, FileText, Cpu } from "lucide-react";
|
||||
import { Play, Loader2, Heart, Clock, FileText, Cpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useChannels } from "@/pages/channels/hooks/use-channels";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants";
|
||||
import { isValidIanaTimezone } from "@/lib/constants";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat";
|
||||
import { HeartbeatScheduleSection } from "./heartbeat-schedule-section";
|
||||
import { HeartbeatAdvancedPanel } from "./heartbeat-advanced-panel";
|
||||
import { HeartbeatDeliverySection } from "./heartbeat-delivery-section";
|
||||
|
||||
interface HeartbeatConfigDialogProps {
|
||||
open: boolean;
|
||||
@@ -34,9 +32,7 @@ interface HeartbeatConfigDialogProps {
|
||||
setChecklist: (content: string) => Promise<void>;
|
||||
fetchTargets: () => Promise<DeliveryTarget[]>;
|
||||
refresh: () => Promise<void>;
|
||||
/** Agent's current provider name (used as default for heartbeat). */
|
||||
agentProvider?: string;
|
||||
/** Agent's current model (used as default for heartbeat). */
|
||||
agentModel?: string;
|
||||
}
|
||||
|
||||
@@ -52,7 +48,6 @@ export function HeartbeatConfigDialog({
|
||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const defaultTz = userTz && userTz !== "auto" ? userTz : browserTz;
|
||||
|
||||
// Form state
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalMin, setIntervalMin] = useState(30);
|
||||
const [ackMaxChars, setAckMaxChars] = useState(300);
|
||||
@@ -69,12 +64,16 @@ export function HeartbeatConfigDialog({
|
||||
const [checklist, setChecklistState] = useState("");
|
||||
const [originalChecklist, setOriginalChecklist] = useState("");
|
||||
const [checklistLoading, setChecklistLoading] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
|
||||
|
||||
const [testRunning, setTestRunning] = useState(false);
|
||||
const showTestSpin = useMinLoading(testRunning, 600);
|
||||
|
||||
const providerNameById = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of providers) map[p.id] = p.name;
|
||||
return map;
|
||||
}, [providers]);
|
||||
|
||||
const loadChecklist = useCallback(async () => {
|
||||
setChecklistLoading(true);
|
||||
try {
|
||||
@@ -86,14 +85,6 @@ export function HeartbeatConfigDialog({
|
||||
}
|
||||
}, [getChecklist]);
|
||||
|
||||
// Map providerId UUID → provider name for the select component.
|
||||
const providerNameById = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of providers) map[p.id] = p.name;
|
||||
return map;
|
||||
}, [providers]);
|
||||
|
||||
// Sync form state only when dialog opens (false→true).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (config) {
|
||||
@@ -108,16 +99,13 @@ export function HeartbeatConfigDialog({
|
||||
setTimezone(config.timezone || defaultTz);
|
||||
setChannel(config.channel ?? "");
|
||||
setChatId(config.chatId ?? "");
|
||||
// Map stored providerId UUID → name for the select
|
||||
setHbProvider(config.providerId ? (providerNameById[config.providerId] ?? "") : "");
|
||||
setHbModel(config.model ?? "");
|
||||
} else {
|
||||
// First-time setup defaults
|
||||
setTimezone(defaultTz);
|
||||
setHbProvider("");
|
||||
setHbModel("");
|
||||
}
|
||||
setShowAdvanced(false);
|
||||
loadChecklist();
|
||||
fetchTargets().then(setTargets).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -138,10 +126,10 @@ export function HeartbeatConfigDialog({
|
||||
await update({
|
||||
enabled,
|
||||
intervalSec: clampedMin * 60,
|
||||
ackMaxChars: ackMaxChars,
|
||||
maxRetries: maxRetries,
|
||||
isolatedSession: isolatedSession,
|
||||
lightContext: lightContext,
|
||||
ackMaxChars,
|
||||
maxRetries,
|
||||
isolatedSession,
|
||||
lightContext,
|
||||
activeHoursStart: activeHoursStart || undefined,
|
||||
activeHoursEnd: activeHoursEnd || undefined,
|
||||
timezone: timezone || undefined,
|
||||
@@ -174,10 +162,9 @@ export function HeartbeatConfigDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Scrollable body — standard pattern: -mx-4 px-4 */}
|
||||
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4 overscroll-contain">
|
||||
|
||||
{/* ── Enable + Interval (top priority) ── */}
|
||||
{/* Enable + Interval */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
@@ -199,7 +186,7 @@ export function HeartbeatConfigDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Provider / Model override ── */}
|
||||
{/* Provider / Model override */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-3.5 w-3.5 text-orange-500" />
|
||||
@@ -220,188 +207,28 @@ export function HeartbeatConfigDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Delivery — WHERE it sends ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-3.5 w-3.5 text-blue-500" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("heartbeat.sectionDelivery")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[140px_1fr]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("heartbeat.channel")}</Label>
|
||||
{channelNames.length > 0 ? (
|
||||
<Select
|
||||
value={channel || "__none__"}
|
||||
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setChatId(""); }}
|
||||
>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("heartbeat.channelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
|
||||
{channelNames.map((ch) => (
|
||||
<SelectItem key={ch} value={ch}>{ch}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="telegram"
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("heartbeat.chatId")}</Label>
|
||||
{(() => {
|
||||
if (!channel) {
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("heartbeat.selectChannelFirst")}
|
||||
disabled
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const filtered = targets.filter((t) => t.channel === channel);
|
||||
if (filtered.length > 0) {
|
||||
return (
|
||||
<Select
|
||||
value={chatId || "__none__"}
|
||||
onValueChange={(v) => setChatId(v === "__none__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("heartbeat.chatIdPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
|
||||
{filtered.map((tgt) => (
|
||||
<SelectItem key={tgt.chatId} value={tgt.chatId}>
|
||||
{tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
placeholder="-100123456789"
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HeartbeatDeliverySection
|
||||
channelNames={channelNames}
|
||||
channel={channel} setChannel={setChannel}
|
||||
chatId={chatId} setChatId={setChatId}
|
||||
targets={targets}
|
||||
/>
|
||||
|
||||
{/* ── Schedule — WHEN it runs ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-amber-500" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("heartbeat.sectionSchedule")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1 w-24">
|
||||
<Label htmlFor="hb-start" className="text-xs">{t("heartbeat.activeHoursStart")}</Label>
|
||||
<Input
|
||||
id="hb-start"
|
||||
placeholder="08:00"
|
||||
value={activeHoursStart}
|
||||
onChange={(e) => setActiveHoursStart(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 w-24">
|
||||
<Label htmlFor="hb-end" className="text-xs">{t("heartbeat.activeHoursEnd")}</Label>
|
||||
<Input
|
||||
id="hb-end"
|
||||
placeholder="22:00"
|
||||
value={activeHoursEnd}
|
||||
onChange={(e) => setActiveHoursEnd(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 flex-1 min-w-[160px]">
|
||||
<Label className="text-xs">{t("heartbeat.timezone")}</Label>
|
||||
<Combobox
|
||||
value={timezone || "__auto__"}
|
||||
onChange={(v) => setTimezone(v === "__auto__" ? "" : v)}
|
||||
options={[{ value: "__auto__", label: defaultTz }, ...getAllIanaTimezones()]}
|
||||
placeholder={t("heartbeat.timezone")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("heartbeat.scheduleHint")}</p>
|
||||
</div>
|
||||
<HeartbeatScheduleSection
|
||||
activeHoursStart={activeHoursStart} setActiveHoursStart={setActiveHoursStart}
|
||||
activeHoursEnd={activeHoursEnd} setActiveHoursEnd={setActiveHoursEnd}
|
||||
timezone={timezone} setTimezone={setTimezone}
|
||||
defaultTz={defaultTz}
|
||||
/>
|
||||
|
||||
{/* ── Advanced (collapsible) ── */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>{t("heartbeat.advancedSettings")}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="border-t px-3 py-3 space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-ack" className="text-xs">{t("heartbeat.ackMaxChars")}</Label>
|
||||
<Input
|
||||
id="hb-ack"
|
||||
type="number"
|
||||
min={0}
|
||||
value={ackMaxChars}
|
||||
onChange={(e) => setAckMaxChars(Number(e.target.value))}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.ackMaxCharsHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-retries" className="text-xs">{t("heartbeat.maxRetries")}</Label>
|
||||
<Input
|
||||
id="hb-retries"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={maxRetries}
|
||||
onChange={(e) => setMaxRetries(Number(e.target.value))}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.maxRetriesHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium">{t("heartbeat.isolatedSession")}</span>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.isolatedSessionHint")}</p>
|
||||
</div>
|
||||
<Switch checked={isolatedSession} onCheckedChange={setIsolatedSession} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium">{t("heartbeat.lightContext")}</span>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.lightContextHint")}</p>
|
||||
</div>
|
||||
<Switch checked={lightContext} onCheckedChange={setLightContext} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<HeartbeatAdvancedPanel
|
||||
ackMaxChars={ackMaxChars} setAckMaxChars={setAckMaxChars}
|
||||
maxRetries={maxRetries} setMaxRetries={setMaxRetries}
|
||||
isolatedSession={isolatedSession} setIsolatedSession={setIsolatedSession}
|
||||
lightContext={lightContext} setLightContext={setLightContext}
|
||||
/>
|
||||
|
||||
{/* ── Checklist — WHAT the agent does ── */}
|
||||
{/* Checklist */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-emerald-500" />
|
||||
@@ -426,22 +253,18 @@ export function HeartbeatConfigDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom padding for scroll */}
|
||||
<div className="h-1" />
|
||||
</div>
|
||||
|
||||
{/* Footer — fixed at bottom */}
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="outline" size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={showTestSpin || saving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{showTestSpin
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Play className="h-3.5 w-3.5" />}
|
||||
{showTestSpin ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{t("heartbeat.testRun")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Send } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat";
|
||||
|
||||
interface HeartbeatDeliverySectionProps {
|
||||
channelNames: string[];
|
||||
channel: string;
|
||||
setChannel: (v: string) => void;
|
||||
chatId: string;
|
||||
setChatId: (v: string) => void;
|
||||
targets: DeliveryTarget[];
|
||||
}
|
||||
|
||||
/** Channel and chat-ID selectors for heartbeat delivery destination. */
|
||||
export function HeartbeatDeliverySection({
|
||||
channelNames,
|
||||
channel, setChannel,
|
||||
chatId, setChatId,
|
||||
targets,
|
||||
}: HeartbeatDeliverySectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-3.5 w-3.5 text-blue-500" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("heartbeat.sectionDelivery")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[140px_1fr]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("heartbeat.channel")}</Label>
|
||||
{channelNames.length > 0 ? (
|
||||
<Select
|
||||
value={channel || "__none__"}
|
||||
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setChatId(""); }}
|
||||
>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("heartbeat.channelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
|
||||
{channelNames.map((ch) => (
|
||||
<SelectItem key={ch} value={ch}>{ch}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="telegram"
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("heartbeat.chatId")}</Label>
|
||||
{(() => {
|
||||
if (!channel) {
|
||||
return <Input placeholder={t("heartbeat.selectChannelFirst")} disabled className="text-base md:text-sm" />;
|
||||
}
|
||||
const filtered = targets.filter((tgt) => tgt.channel === channel);
|
||||
if (filtered.length > 0) {
|
||||
return (
|
||||
<Select value={chatId || "__none__"} onValueChange={(v) => setChatId(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("heartbeat.chatIdPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
|
||||
{filtered.map((tgt) => (
|
||||
<SelectItem key={tgt.chatId} value={tgt.chatId}>
|
||||
{tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
placeholder="-100123456789"
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Clock } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getAllIanaTimezones } from "@/lib/constants";
|
||||
|
||||
interface HeartbeatScheduleSectionProps {
|
||||
activeHoursStart: string;
|
||||
setActiveHoursStart: (v: string) => void;
|
||||
activeHoursEnd: string;
|
||||
setActiveHoursEnd: (v: string) => void;
|
||||
timezone: string;
|
||||
setTimezone: (v: string) => void;
|
||||
defaultTz: string;
|
||||
}
|
||||
|
||||
/** Active-hours and timezone controls for the heartbeat schedule. */
|
||||
export function HeartbeatScheduleSection({
|
||||
activeHoursStart, setActiveHoursStart,
|
||||
activeHoursEnd, setActiveHoursEnd,
|
||||
timezone, setTimezone,
|
||||
defaultTz,
|
||||
}: HeartbeatScheduleSectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-amber-500" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("heartbeat.sectionSchedule")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1 w-24">
|
||||
<Label htmlFor="hb-start" className="text-xs">{t("heartbeat.activeHoursStart")}</Label>
|
||||
<Input
|
||||
id="hb-start"
|
||||
placeholder="08:00"
|
||||
value={activeHoursStart}
|
||||
onChange={(e) => setActiveHoursStart(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 w-24">
|
||||
<Label htmlFor="hb-end" className="text-xs">{t("heartbeat.activeHoursEnd")}</Label>
|
||||
<Input
|
||||
id="hb-end"
|
||||
placeholder="22:00"
|
||||
value={activeHoursEnd}
|
||||
onChange={(e) => setActiveHoursEnd(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 flex-1 min-w-[160px]">
|
||||
<Label className="text-xs">{t("heartbeat.timezone")}</Label>
|
||||
<Combobox
|
||||
value={timezone || "__auto__"}
|
||||
onChange={(v) => setTimezone(v === "__auto__" ? "" : v)}
|
||||
options={[{ value: "__auto__", label: defaultTz }, ...getAllIanaTimezones()]}
|
||||
placeholder={t("heartbeat.timezone")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("heartbeat.scheduleHint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { BuiltinToolData } from "./hooks/use-builtin-tools";
|
||||
import { ToolRow } from "./builtin-tool-row";
|
||||
|
||||
interface CategoryGroupProps {
|
||||
category: string;
|
||||
tools: BuiltinToolData[];
|
||||
onToggle: (tool: BuiltinToolData) => void;
|
||||
onSettings: (tool: BuiltinToolData) => void;
|
||||
tenantId: string | null;
|
||||
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CategoryGroup({
|
||||
category,
|
||||
tools,
|
||||
onToggle,
|
||||
onSettings,
|
||||
tenantId,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: CategoryGroupProps) {
|
||||
const { t } = useTranslation("tools");
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2">
|
||||
<span className="text-sm font-medium">{t(`builtin.categories.${category}`, category)}</span>
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[11px]">
|
||||
{tools.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{tools.map((tool) => (
|
||||
<ToolRow
|
||||
key={tool.name}
|
||||
tool={tool}
|
||||
onToggle={onToggle}
|
||||
onSettings={onSettings}
|
||||
tenantId={tenantId}
|
||||
onSetTenantConfig={onSetTenantConfig}
|
||||
onDeleteTenantConfig={onDeleteTenantConfig}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Settings, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { BuiltinToolData } from "./hooks/use-builtin-tools";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function hasEditableSettings(tool: BuiltinToolData): boolean {
|
||||
return tool.settings != null && Object.keys(tool.settings).length > 0;
|
||||
}
|
||||
|
||||
export function getConfigHint(tool: BuiltinToolData): string | undefined {
|
||||
return (tool.metadata as any)?.config_hint as string | undefined;
|
||||
}
|
||||
|
||||
export function isDeprecated(tool: BuiltinToolData): boolean {
|
||||
return (tool.metadata as any)?.deprecated === true;
|
||||
}
|
||||
|
||||
interface TenantOverrideControlProps {
|
||||
tool: BuiltinToolData;
|
||||
hasOverride: boolean;
|
||||
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TenantOverrideControl({
|
||||
tool,
|
||||
hasOverride,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: TenantOverrideControlProps) {
|
||||
const { t } = useTranslation("tools");
|
||||
|
||||
const tenantEnabled = tool.tenant_enabled;
|
||||
const overrideLabel = hasOverride
|
||||
? tenantEnabled
|
||||
? t("builtin.tenantEnabled")
|
||||
: t("builtin.tenantDisabled")
|
||||
: t("builtin.tenantDefault");
|
||||
|
||||
const badgeVariant = hasOverride
|
||||
? tenantEnabled
|
||||
? "default"
|
||||
: "secondary"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-r pr-2 mr-0.5">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={badgeVariant}
|
||||
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
|
||||
>
|
||||
{overrideLabel}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.tenantOverride")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Switch
|
||||
checked={hasOverride ? (tenantEnabled ?? false) : tool.enabled}
|
||||
onCheckedChange={(checked) => onSetTenantConfig(tool.name, checked)}
|
||||
className="scale-75 origin-right"
|
||||
aria-label={t("builtin.tenantOverride")}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteTenantConfig(tool.name)}
|
||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("builtin.resetToDefault")}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.resetToDefault")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolRowProps {
|
||||
tool: BuiltinToolData;
|
||||
onToggle: (tool: BuiltinToolData) => void;
|
||||
onSettings: (tool: BuiltinToolData) => void;
|
||||
tenantId: string | null;
|
||||
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ToolRow({
|
||||
tool,
|
||||
onToggle,
|
||||
onSettings,
|
||||
tenantId,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: ToolRowProps) {
|
||||
const { t } = useTranslation("tools");
|
||||
const configHint = getConfigHint(tool);
|
||||
const editable = hasEditableSettings(tool);
|
||||
const deprecated = isDeprecated(tool);
|
||||
const hasTenantScope = !!tenantId && tenantId !== "0193a5b0-7000-7000-8000-000000000001";
|
||||
const hasOverride = tool.tenant_enabled !== null && tool.tenant_enabled !== undefined;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-4 px-4 py-2 hover:bg-muted/30 transition-colors${deprecated ? " opacity-60" : ""}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-sm font-medium leading-tight">{tool.display_name}</span>
|
||||
<code className="text-[11px] text-muted-foreground">{tool.name}</code>
|
||||
{deprecated && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="destructive" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
|
||||
{t("builtin.deprecated")}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.deprecatedTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!deprecated && tool.requires && tool.requires.length > 0 && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
|
||||
{t("builtin.requires")}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.requiresTooltip", { list: tool.requires.join(", ") })}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground leading-snug truncate mt-0.5">
|
||||
{t(`builtin.descriptions.${tool.name}`, tool.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{editable && !deprecated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSettings(tool)}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
{t("builtin.settings")}
|
||||
</Button>
|
||||
)}
|
||||
{!editable && !deprecated && configHint && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
|
||||
<Info className="h-3 w-3" />
|
||||
{configHint}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.configuredVia")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasTenantScope && !deprecated ? (
|
||||
<TenantOverrideControl
|
||||
tool={tool}
|
||||
hasOverride={hasOverride}
|
||||
onSetTenantConfig={onSetTenantConfig}
|
||||
onDeleteTenantConfig={onDeleteTenantConfig}
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => onToggle(tool)}
|
||||
disabled={deprecated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Package, RefreshCw, Settings, Info, AlertTriangle } from "lucide-react";
|
||||
import { Package, RefreshCw, Settings, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
@@ -20,26 +12,13 @@ import { MEDIA_TOOLS } from "./media-provider-params-schema";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { useTenants } from "@/hooks/use-tenants";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { CategoryGroup } from "./builtin-tool-category-group";
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"filesystem", "runtime", "web", "memory", "media", "browser",
|
||||
"sessions", "messaging", "scheduling", "subagents", "skills", "delegation", "teams",
|
||||
];
|
||||
|
||||
function hasEditableSettings(tool: BuiltinToolData): boolean {
|
||||
return tool.settings != null && Object.keys(tool.settings).length > 0;
|
||||
}
|
||||
|
||||
function getConfigHint(tool: BuiltinToolData): string | undefined {
|
||||
return (tool.metadata as any)?.config_hint as string | undefined;
|
||||
}
|
||||
|
||||
function isDeprecated(tool: BuiltinToolData): boolean {
|
||||
return (tool.metadata as any)?.deprecated === true;
|
||||
}
|
||||
|
||||
/** Media tool that is enabled but has no provider chain configured */
|
||||
function needsProviderConfig(tool: BuiltinToolData): boolean {
|
||||
if (!MEDIA_TOOLS.has(tool.name) || !tool.enabled) return false;
|
||||
@@ -187,228 +166,3 @@ export function BuiltinToolsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryGroup({
|
||||
category,
|
||||
tools,
|
||||
onToggle,
|
||||
onSettings,
|
||||
tenantId,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: {
|
||||
category: string;
|
||||
tools: BuiltinToolData[];
|
||||
onToggle: (tool: BuiltinToolData) => void;
|
||||
onSettings: (tool: BuiltinToolData) => void;
|
||||
tenantId: string | null;
|
||||
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (name: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation("tools");
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2">
|
||||
<span className="text-sm font-medium">{t(`builtin.categories.${category}`, category)}</span>
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[11px]">
|
||||
{tools.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{tools.map((tool) => (
|
||||
<ToolRow
|
||||
key={tool.name}
|
||||
tool={tool}
|
||||
onToggle={onToggle}
|
||||
onSettings={onSettings}
|
||||
tenantId={tenantId}
|
||||
onSetTenantConfig={onSetTenantConfig}
|
||||
onDeleteTenantConfig={onDeleteTenantConfig}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRow({
|
||||
tool,
|
||||
onToggle,
|
||||
onSettings,
|
||||
tenantId,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: {
|
||||
tool: BuiltinToolData;
|
||||
onToggle: (tool: BuiltinToolData) => void;
|
||||
onSettings: (tool: BuiltinToolData) => void;
|
||||
tenantId: string | null;
|
||||
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (name: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation("tools");
|
||||
const configHint = getConfigHint(tool);
|
||||
const editable = hasEditableSettings(tool);
|
||||
const deprecated = isDeprecated(tool);
|
||||
const hasTenantScope = !!tenantId && tenantId !== "0193a5b0-7000-7000-8000-000000000001";
|
||||
const hasOverride = tool.tenant_enabled !== null && tool.tenant_enabled !== undefined;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-4 px-4 py-2 hover:bg-muted/30 transition-colors${deprecated ? " opacity-60" : ""}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-sm font-medium leading-tight">{tool.display_name}</span>
|
||||
<code className="text-[11px] text-muted-foreground">{tool.name}</code>
|
||||
{deprecated && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="destructive" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
|
||||
{t("builtin.deprecated")}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.deprecatedTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!deprecated && tool.requires && tool.requires.length > 0 && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
|
||||
{t("builtin.requires")}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.requiresTooltip", { list: tool.requires.join(", ") })}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground leading-snug truncate mt-0.5">
|
||||
{t(`builtin.descriptions.${tool.name}`, tool.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{editable && !deprecated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSettings(tool)}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
{t("builtin.settings")}
|
||||
</Button>
|
||||
)}
|
||||
{!editable && !deprecated && configHint && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
|
||||
<Info className="h-3 w-3" />
|
||||
{configHint}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.configuredVia")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasTenantScope && !deprecated ? (
|
||||
<TenantOverrideControl
|
||||
tool={tool}
|
||||
hasOverride={hasOverride}
|
||||
onSetTenantConfig={onSetTenantConfig}
|
||||
onDeleteTenantConfig={onDeleteTenantConfig}
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => onToggle(tool)}
|
||||
disabled={deprecated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantOverrideControl({
|
||||
tool,
|
||||
hasOverride,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: {
|
||||
tool: BuiltinToolData;
|
||||
hasOverride: boolean;
|
||||
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (name: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation("tools");
|
||||
|
||||
const tenantEnabled = tool.tenant_enabled;
|
||||
const overrideLabel = hasOverride
|
||||
? tenantEnabled
|
||||
? t("builtin.tenantEnabled")
|
||||
: t("builtin.tenantDisabled")
|
||||
: t("builtin.tenantDefault");
|
||||
|
||||
const badgeVariant = hasOverride
|
||||
? tenantEnabled
|
||||
? "default"
|
||||
: "secondary"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-r pr-2 mr-0.5">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={badgeVariant}
|
||||
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
|
||||
>
|
||||
{overrideLabel}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.tenantOverride")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Switch
|
||||
checked={hasOverride ? (tenantEnabled ?? false) : tool.enabled}
|
||||
onCheckedChange={(checked) => onSetTenantConfig(tool.name, checked)}
|
||||
className="scale-75 origin-right"
|
||||
aria-label={t("builtin.tenantOverride")}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteTenantConfig(tool.name)}
|
||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("builtin.resetToDefault")}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("builtin.resetToDefault")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ParamField } from "./media-provider-params-schema";
|
||||
|
||||
/** Renders a single provider param field based on its type declaration. */
|
||||
export function ParamFieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: ParamField;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{field.label}</Label>
|
||||
{field.type === "select" && field.options && (
|
||||
<Select value={String(value ?? "")} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{field.type === "toggle" && (
|
||||
<div className="flex items-center h-8">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{field.type === "number" && (
|
||||
<Input
|
||||
type="number"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={Number(value ?? 0)}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
{field.type === "text" && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.description}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,40 +12,17 @@ import {
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical, Trash2, ChevronDown, ChevronUp, Plus, Loader2 } from "lucide-react";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { uniqueId } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
|
||||
import { MEDIA_PARAMS_SCHEMA, type ParamField } from "./media-provider-params-schema";
|
||||
|
||||
interface ProviderEntry {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
timeout: number;
|
||||
max_retries: number;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
import { SortableProviderCard } from "./media-sortable-provider-card";
|
||||
import type { ProviderEntry } from "./media-provider-chain-helpers";
|
||||
import { formatToolTitle, parseInitialEntries } from "./media-provider-chain-helpers";
|
||||
|
||||
interface MediaProviderChainFormProps {
|
||||
toolName: string;
|
||||
@@ -54,322 +31,7 @@ interface MediaProviderChainFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatToolTitle(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function buildDefaultParams(toolName: string, providerType: string): Record<string, unknown> {
|
||||
const schema = MEDIA_PARAMS_SCHEMA[toolName]?.[providerType] ?? [];
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const field of schema) {
|
||||
if (field.default !== undefined) {
|
||||
defaults[field.key] = field.default;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function parseInitialEntries(
|
||||
settings: Record<string, unknown>,
|
||||
providers: ReturnType<typeof useProviders>["providers"],
|
||||
): ProviderEntry[] {
|
||||
// New format: { providers: [...] }
|
||||
// Only include entries whose provider exists in the current tenant.
|
||||
if (Array.isArray(settings.providers)) {
|
||||
return (settings.providers as Record<string, unknown>[])
|
||||
.map((p) => {
|
||||
const name = String(p.provider ?? "");
|
||||
const pid = String(p.provider_id ?? "");
|
||||
// Resolve provider_id: use stored id if valid, otherwise look up by name
|
||||
const resolved = (pid && providers.some((pr) => pr.id === pid))
|
||||
? pid
|
||||
: providers.find((pr) => pr.name === name)?.id ?? "";
|
||||
// Skip entries whose provider doesn't exist in this tenant
|
||||
if (!resolved) return null;
|
||||
return {
|
||||
id: uniqueId(),
|
||||
provider_id: resolved,
|
||||
provider: name,
|
||||
model: String(p.model ?? ""),
|
||||
enabled: Boolean(p.enabled ?? true),
|
||||
timeout: Number(p.timeout ?? 120),
|
||||
max_retries: Number(p.max_retries ?? 2),
|
||||
params: (p.params as Record<string, unknown>) ?? {},
|
||||
};
|
||||
})
|
||||
.filter((e): e is ProviderEntry => e !== null);
|
||||
}
|
||||
|
||||
// Legacy format: { provider, model }
|
||||
// Only show if the provider exists in the current tenant's provider list.
|
||||
if (settings.provider || settings.model) {
|
||||
const providerName = String(settings.provider ?? "");
|
||||
const providerData = providers.find((p) => p.name === providerName);
|
||||
if (providerData) {
|
||||
return [
|
||||
{
|
||||
id: uniqueId(),
|
||||
provider_id: providerData.id,
|
||||
provider: providerName,
|
||||
model: String(settings.model ?? ""),
|
||||
enabled: true,
|
||||
timeout: 120,
|
||||
max_retries: 2,
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
// Provider not available in this tenant — show empty chain
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sub-component for a single sortable provider card
|
||||
interface SortableCardProps {
|
||||
entry: ProviderEntry;
|
||||
index: number;
|
||||
toolName: string;
|
||||
enabledProviders: ReturnType<typeof useProviders>["providers"];
|
||||
onUpdate: (id: string, patch: Partial<ProviderEntry>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
portalRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function SortableProviderCard({ entry, index, toolName, enabledProviders, onUpdate, onRemove, portalRef }: SortableCardProps) {
|
||||
const { t } = useTranslation("tools");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: entry.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const selectedProvider = enabledProviders.find((p) => p.id === entry.provider_id);
|
||||
const { models, loading: modelsLoading } = useProviderModels(
|
||||
entry.provider_id || undefined,
|
||||
);
|
||||
|
||||
const paramSchema: ParamField[] =
|
||||
MEDIA_PARAMS_SCHEMA[toolName]?.[selectedProvider?.provider_type ?? ""] ?? [];
|
||||
|
||||
const handleProviderChange = (providerName: string) => {
|
||||
const pData = enabledProviders.find((p) => p.name === providerName);
|
||||
const newParams = pData
|
||||
? buildDefaultParams(toolName, pData.provider_type)
|
||||
: {};
|
||||
onUpdate(entry.id, {
|
||||
provider_id: pData?.id ?? "",
|
||||
provider: providerName,
|
||||
model: "",
|
||||
params: newParams,
|
||||
});
|
||||
};
|
||||
|
||||
const handleParamChange = (key: string, value: unknown) => {
|
||||
onUpdate(entry.id, { params: { ...entry.params, [key]: value } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`border rounded-lg bg-card ${!entry.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
{/* Row 1: drag handle, index, toggle, delete */}
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab text-muted-foreground hover:text-foreground shrink-0"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
||||
#{index + 1}
|
||||
</span>
|
||||
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={entry.enabled}
|
||||
onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })}
|
||||
/>
|
||||
|
||||
<span className="text-sm font-medium truncate">
|
||||
{selectedProvider?.display_name || entry.provider || t("builtin.mediaChain.newProvider")}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemove(entry.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: provider + model selects */}
|
||||
<div className="grid grid-cols-1 gap-2 px-3 py-1.5 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.provider")}</Label>
|
||||
<Select value={entry.provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder={t("builtin.mediaChain.selectProvider")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.display_name || p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.model")}</Label>
|
||||
<Combobox
|
||||
value={entry.model}
|
||||
onChange={(v) => onUpdate(entry.id, { model: v })}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name ?? m.id }))}
|
||||
placeholder={modelsLoading ? t("builtin.mediaChain.loadingModels") : t("builtin.mediaChain.selectModel")}
|
||||
className="h-8 text-sm"
|
||||
portalContainer={portalRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: timeout, retries, expand button */}
|
||||
<div className="flex items-center gap-3 px-3 pb-3 pt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">{t("builtin.mediaChain.timeout")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
value={entry.timeout}
|
||||
onChange={(e) => onUpdate(entry.id, { timeout: Number(e.target.value) })}
|
||||
className="h-7 w-20 text-sm pr-5"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">{t("builtin.mediaChain.retries")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={entry.max_retries}
|
||||
onChange={(e) => onUpdate(entry.id, { max_retries: Number(e.target.value) })}
|
||||
className="h-7 w-16 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{paramSchema.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="ml-auto flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("builtin.mediaChain.settings")}
|
||||
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsible params */}
|
||||
{expanded && paramSchema.length > 0 && (
|
||||
<div className="border-t px-3 py-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{paramSchema.map((field) => (
|
||||
<ParamFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={entry.params[field.key] ?? field.default}
|
||||
onChange={(v) => handleParamChange(field.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Renders a single param field based on its type
|
||||
function ParamFieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: ParamField;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{field.label}</Label>
|
||||
{field.type === "select" && field.options && (
|
||||
<Select value={String(value ?? "")} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{field.type === "toggle" && (
|
||||
<div className="flex items-center h-8">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{field.type === "number" && (
|
||||
<Input
|
||||
type="number"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={Number(value ?? 0)}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
{field.type === "text" && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.description}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main form component
|
||||
/** Dialog form for configuring an ordered provider chain for a media tool. */
|
||||
export function MediaProviderChainForm({
|
||||
toolName,
|
||||
initialSettings,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { uniqueId } from "@/lib/utils";
|
||||
import { MEDIA_PARAMS_SCHEMA } from "./media-provider-params-schema";
|
||||
export interface ProviderEntry {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
timeout: number;
|
||||
max_retries: number;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Convert tool_name to display title (e.g. "text_to_speech" → "Text To Speech"). */
|
||||
export function formatToolTitle(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Build default param values for a given tool + provider type from schema. */
|
||||
export function buildDefaultParams(toolName: string, providerType: string): Record<string, unknown> {
|
||||
const schema = MEDIA_PARAMS_SCHEMA[toolName]?.[providerType] ?? [];
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const field of schema) {
|
||||
if (field.default !== undefined) {
|
||||
defaults[field.key] = field.default;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/** Parse stored settings into ProviderEntry list, filtering out unavailable providers. */
|
||||
export function parseInitialEntries(
|
||||
settings: Record<string, unknown>,
|
||||
providers: Array<{ id: string; name: string }>,
|
||||
): ProviderEntry[] {
|
||||
// New format: { providers: [...] }
|
||||
if (Array.isArray(settings.providers)) {
|
||||
return (settings.providers as Record<string, unknown>[])
|
||||
.map((p) => {
|
||||
const name = String(p.provider ?? "");
|
||||
const pid = String(p.provider_id ?? "");
|
||||
const resolved = (pid && providers.some((pr) => pr.id === pid))
|
||||
? pid
|
||||
: providers.find((pr) => pr.name === name)?.id ?? "";
|
||||
if (!resolved) return null;
|
||||
return {
|
||||
id: uniqueId(),
|
||||
provider_id: resolved,
|
||||
provider: name,
|
||||
model: String(p.model ?? ""),
|
||||
enabled: Boolean(p.enabled ?? true),
|
||||
timeout: Number(p.timeout ?? 120),
|
||||
max_retries: Number(p.max_retries ?? 2),
|
||||
params: (p.params as Record<string, unknown>) ?? {},
|
||||
};
|
||||
})
|
||||
.filter((e): e is ProviderEntry => e !== null);
|
||||
}
|
||||
|
||||
// Legacy format: { provider, model }
|
||||
if (settings.provider || settings.model) {
|
||||
const providerName = String(settings.provider ?? "");
|
||||
const providerData = providers.find((p) => p.name === providerName);
|
||||
if (providerData) {
|
||||
return [
|
||||
{
|
||||
id: uniqueId(),
|
||||
provider_id: providerData.id,
|
||||
provider: providerName,
|
||||
model: String(settings.model ?? ""),
|
||||
enabled: true,
|
||||
timeout: 120,
|
||||
max_retries: 2,
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical, Trash2, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
|
||||
import { MEDIA_PARAMS_SCHEMA } from "./media-provider-params-schema";
|
||||
import { ParamFieldControl } from "./media-param-field-control";
|
||||
import { buildDefaultParams } from "./media-provider-chain-helpers";
|
||||
|
||||
import type { ProviderEntry } from "./media-provider-chain-helpers";
|
||||
|
||||
interface SortableCardProps {
|
||||
entry: ProviderEntry;
|
||||
index: number;
|
||||
toolName: string;
|
||||
enabledProviders: ReturnType<typeof useProviders>["providers"];
|
||||
onUpdate: (id: string, patch: Partial<ProviderEntry>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
portalRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
/** Single draggable/sortable provider card in the media chain form. */
|
||||
export function SortableProviderCard({
|
||||
entry,
|
||||
index,
|
||||
toolName,
|
||||
enabledProviders,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
portalRef,
|
||||
}: SortableCardProps) {
|
||||
const { t } = useTranslation("tools");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: entry.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const selectedProvider = enabledProviders.find((p) => p.id === entry.provider_id);
|
||||
const { models, loading: modelsLoading } = useProviderModels(
|
||||
entry.provider_id || undefined,
|
||||
);
|
||||
|
||||
const paramSchema = MEDIA_PARAMS_SCHEMA[toolName]?.[selectedProvider?.provider_type ?? ""] ?? [];
|
||||
|
||||
const handleProviderChange = (providerName: string) => {
|
||||
const pData = enabledProviders.find((p) => p.name === providerName);
|
||||
const newParams = pData ? buildDefaultParams(toolName, pData.provider_type) : {};
|
||||
onUpdate(entry.id, {
|
||||
provider_id: pData?.id ?? "",
|
||||
provider: providerName,
|
||||
model: "",
|
||||
params: newParams,
|
||||
});
|
||||
};
|
||||
|
||||
const handleParamChange = (key: string, value: unknown) => {
|
||||
onUpdate(entry.id, { params: { ...entry.params, [key]: value } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`border rounded-lg bg-card ${!entry.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
{/* Row 1: drag handle, index, toggle, delete */}
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab text-muted-foreground hover:text-foreground shrink-0"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
||||
#{index + 1}
|
||||
</span>
|
||||
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={entry.enabled}
|
||||
onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })}
|
||||
/>
|
||||
|
||||
<span className="text-sm font-medium truncate">
|
||||
{selectedProvider?.display_name || entry.provider || t("builtin.mediaChain.newProvider")}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemove(entry.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: provider + model selects */}
|
||||
<div className="grid grid-cols-1 gap-2 px-3 py-1.5 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.provider")}</Label>
|
||||
<Select value={entry.provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder={t("builtin.mediaChain.selectProvider")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.display_name || p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.model")}</Label>
|
||||
<Combobox
|
||||
value={entry.model}
|
||||
onChange={(v) => onUpdate(entry.id, { model: v })}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name ?? m.id }))}
|
||||
placeholder={modelsLoading ? t("builtin.mediaChain.loadingModels") : t("builtin.mediaChain.selectModel")}
|
||||
className="h-8 text-sm"
|
||||
portalContainer={portalRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: timeout, retries, expand button */}
|
||||
<div className="flex items-center gap-3 px-3 pb-3 pt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">{t("builtin.mediaChain.timeout")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
value={entry.timeout}
|
||||
onChange={(e) => onUpdate(entry.id, { timeout: Number(e.target.value) })}
|
||||
className="h-7 w-20 text-sm pr-5"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">{t("builtin.mediaChain.retries")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={entry.max_retries}
|
||||
onChange={(e) => onUpdate(entry.id, { max_retries: Number(e.target.value) })}
|
||||
className="h-7 w-16 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{paramSchema.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="ml-auto flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("builtin.mediaChain.settings")}
|
||||
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsible params */}
|
||||
{expanded && paramSchema.length > 0 && (
|
||||
<div className="border-t px-3 py-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{paramSchema.map((field) => (
|
||||
<ParamFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={entry.params[field.key] ?? field.default}
|
||||
onChange={(v) => handleParamChange(field.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* InlineAddForm — per-group manager add form used inside ChannelManagersTab.
|
||||
* Two render modes:
|
||||
* - with groupId prop: compact inline form (no group ID field)
|
||||
* - showGroupField: expanded form with group ID + user ID fields
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
|
||||
|
||||
export interface InlineAddFormProps {
|
||||
groupId?: string;
|
||||
showGroupField?: boolean;
|
||||
onAdd: (groupId: string, userId: string, displayName: string, username: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function InlineAddForm({ groupId, showGroupField, onAdd }: InlineAddFormProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
const [formGroupId, setFormGroupId] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const gid = groupId || formGroupId.trim();
|
||||
const uid = userId.trim();
|
||||
if (!gid || !uid) {
|
||||
setError(t("detail.managers.addForm.errors.groupUserRequired"));
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setError("");
|
||||
try {
|
||||
await onAdd(gid, uid, "", "");
|
||||
setUserId("");
|
||||
if (!groupId) setFormGroupId("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("detail.managers.addForm.errors.failedAdd"));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showGroupField) {
|
||||
return (
|
||||
<fieldset className="rounded-md border p-4 space-y-3">
|
||||
<legend className="px-1 text-sm font-medium">{t("detail.managers.addForm.title")}</legend>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.managers.addForm.hint")}</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid gap-1.5 flex-1 min-w-[180px]">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.groupId")}</Label>
|
||||
<Input
|
||||
value={formGroupId}
|
||||
onChange={(e) => setFormGroupId(e.target.value)}
|
||||
placeholder={t("detail.managers.addForm.groupIdPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 flex-1 min-w-[180px]">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.userId")}</Label>
|
||||
<UserPickerCombobox
|
||||
value={userId}
|
||||
onChange={setUserId}
|
||||
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={adding || !formGroupId.trim() || !userId.trim()}
|
||||
size="sm"
|
||||
className="h-9 gap-1 shrink-0"
|
||||
>
|
||||
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{t("detail.managers.addForm.addManager")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive mt-1">{error}</p>}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1 flex-1 min-w-[140px]">
|
||||
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.userId")}</Label>
|
||||
<UserPickerCombobox
|
||||
value={userId}
|
||||
onChange={setUserId}
|
||||
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1 shrink-0"
|
||||
onClick={handleSubmit}
|
||||
disabled={adding || !userId.trim()}
|
||||
>
|
||||
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{t("detail.managers.addForm.add")}
|
||||
</Button>
|
||||
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Loader2, RefreshCw, Users, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Trash2, Loader2, RefreshCw, Users, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
|
||||
import type { GroupManagerGroupInfo, GroupManagerData } from "../hooks/use-channel-detail";
|
||||
import { InlineAddForm } from "./channel-manager-inline-add-form";
|
||||
|
||||
interface ChannelManagersTabProps {
|
||||
listManagerGroups: () => Promise<GroupManagerGroupInfo[]>;
|
||||
@@ -21,106 +19,6 @@ function shortGroupId(id: string): string {
|
||||
return m?.[1] ?? id;
|
||||
}
|
||||
|
||||
// --- Inline add form (scoped state per instance) ---
|
||||
|
||||
interface InlineAddFormProps {
|
||||
groupId?: string;
|
||||
showGroupField?: boolean;
|
||||
onAdd: (groupId: string, userId: string, displayName: string, username: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function InlineAddForm({ groupId, showGroupField, onAdd }: InlineAddFormProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
const [formGroupId, setFormGroupId] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const gid = groupId || formGroupId.trim();
|
||||
const uid = userId.trim();
|
||||
if (!gid || !uid) {
|
||||
setError(t("detail.managers.addForm.errors.groupUserRequired"));
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setError("");
|
||||
try {
|
||||
await onAdd(gid, uid, "", "");
|
||||
setUserId("");
|
||||
if (!groupId) setFormGroupId("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("detail.managers.addForm.errors.failedAdd"));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showGroupField) {
|
||||
return (
|
||||
<fieldset className="rounded-md border p-4 space-y-3">
|
||||
<legend className="px-1 text-sm font-medium">{t("detail.managers.addForm.title")}</legend>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.managers.addForm.hint")}</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid gap-1.5 flex-1 min-w-[180px]">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.groupId")}</Label>
|
||||
<Input
|
||||
value={formGroupId}
|
||||
onChange={(e) => setFormGroupId(e.target.value)}
|
||||
placeholder={t("detail.managers.addForm.groupIdPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 flex-1 min-w-[180px]">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.userId")}</Label>
|
||||
<UserPickerCombobox
|
||||
value={userId}
|
||||
onChange={setUserId}
|
||||
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={adding || !formGroupId.trim() || !userId.trim()}
|
||||
size="sm"
|
||||
className="h-9 gap-1 shrink-0"
|
||||
>
|
||||
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{t("detail.managers.addForm.addManager")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive mt-1">{error}</p>}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1 flex-1 min-w-[140px]">
|
||||
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.userId")}</Label>
|
||||
<UserPickerCombobox
|
||||
value={userId}
|
||||
onChange={setUserId}
|
||||
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1 shrink-0"
|
||||
onClick={handleSubmit}
|
||||
disabled={adding || !userId.trim()}
|
||||
>
|
||||
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{t("detail.managers.addForm.add")}
|
||||
</Button>
|
||||
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main tab component ---
|
||||
|
||||
export function ChannelManagersTab({
|
||||
listManagerGroups,
|
||||
listManagers,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Message } from "@/types/session";
|
||||
import type { ChatMessage, ToolStreamEntry, MediaItem } from "@/types/chat";
|
||||
import { toFileUrl } from "@/lib/file-helpers";
|
||||
import { messageToTimestamp } from "@/lib/message-utils";
|
||||
|
||||
/**
|
||||
* Transform raw Message[] from history RPC into ChatMessage[] for display.
|
||||
* Reconstructs toolDetails from tool_calls + tool result messages,
|
||||
* and converts media_refs to MediaItem[] for gallery.
|
||||
*/
|
||||
export function transformHistoryMessages(
|
||||
allMsgs: Message[],
|
||||
mediaItems?: MediaItem[],
|
||||
): ChatMessage[] {
|
||||
// Build a map of tool_call_id -> tool message for result lookup
|
||||
const toolResultMap = new Map<string, Message>();
|
||||
for (const m of allMsgs) {
|
||||
if (m.role === "tool" && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, m);
|
||||
}
|
||||
}
|
||||
|
||||
const msgs: ChatMessage[] = allMsgs.map((m: Message, i: number) => {
|
||||
const chatMsg: ChatMessage = {
|
||||
...m,
|
||||
timestamp: messageToTimestamp(m, i, allMsgs.length),
|
||||
};
|
||||
|
||||
// Convert persisted media_refs to mediaItems for gallery display
|
||||
if (m.role === "assistant" && m.media_refs && m.media_refs.length > 0) {
|
||||
chatMsg.mediaItems = m.media_refs.map((ref) => ({
|
||||
path: toFileUrl(ref.path || ref.id),
|
||||
mimeType: ref.mime_type,
|
||||
fileName: (ref.path?.split("?")[0]?.split("/").pop()) ?? ref.id,
|
||||
kind: (ref.kind as MediaItem["kind"]) || "document",
|
||||
}));
|
||||
}
|
||||
|
||||
// Reconstruct toolDetails for assistant messages with tool_calls
|
||||
if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) {
|
||||
chatMsg.toolDetails = m.tool_calls.map((tc) => {
|
||||
const toolMsg = toolResultMap.get(tc.id);
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
runId: "",
|
||||
name: tc.name,
|
||||
phase: (toolMsg ? (toolMsg.is_error ? "error" : "completed") : "calling") as ToolStreamEntry["phase"],
|
||||
startedAt: 0,
|
||||
updatedAt: 0,
|
||||
arguments: tc.arguments,
|
||||
result: toolMsg?.content,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return chatMsg;
|
||||
});
|
||||
|
||||
// Attach media to the last assistant message if provided
|
||||
if (mediaItems?.length && msgs.length > 0) {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i]!.role === "assistant") {
|
||||
msgs[i] = { ...msgs[i]!, mediaItems };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msgs;
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Methods, Events } from "@/api/protocol";
|
||||
import type { Message } from "@/types/session";
|
||||
import type { ChatMessage, AgentEventPayload, ToolStreamEntry, RunActivity, ActiveTeamTask, MediaItem } from "@/types/chat";
|
||||
import { toFileUrl, mediaKindFromMime } from "@/lib/file-helpers";
|
||||
import { messageToTimestamp } from "@/lib/message-utils";
|
||||
import { transformHistoryMessages } from "./chat-message-transformer";
|
||||
import { useChatTeamTasks } from "./use-chat-team-tasks";
|
||||
|
||||
/**
|
||||
* Manages chat message history and real-time streaming for a session.
|
||||
* Listens to "agent" events for chunks, tool calls, and run lifecycle.
|
||||
*
|
||||
* The runId is captured from the first "run.started" event (not from the
|
||||
* chat.send RPC response, which only arrives after the run completes).
|
||||
*/
|
||||
export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
const ws = useWs();
|
||||
@@ -24,9 +22,8 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activity, setActivity] = useState<RunActivity | null>(null);
|
||||
const [blockReplies, setBlockReplies] = useState<ChatMessage[]>([]);
|
||||
const [teamTasks, setTeamTasks] = useState<ActiveTeamTask[]>([]);
|
||||
|
||||
// Use refs for values accessed inside the event handler to avoid stale closures.
|
||||
// Refs for values accessed inside event handler to avoid stale closures
|
||||
const runIdRef = useRef<string | null>(null);
|
||||
const expectingRunRef = useRef(false);
|
||||
const streamRef = useRef("");
|
||||
@@ -41,221 +38,108 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
const rafPendingRef = useRef(false);
|
||||
const rafHandleRef = useRef(0);
|
||||
|
||||
// Reset streaming/run state when session changes.
|
||||
// Messages are NOT cleared here — loadHistory() will replace them atomically.
|
||||
// loading is NOT set to true — avoids full-page flash while history loads.
|
||||
// Add a local message optimistically
|
||||
const addLocalMessage = useCallback((msg: ChatMessage) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
// Team task handling (extracted hook)
|
||||
const { teamTasks, setTeamTasks } = useChatTeamTasks(addLocalMessage);
|
||||
|
||||
// Reset streaming/run state when session changes
|
||||
const prevKeyRef = useRef(sessionKey);
|
||||
useEffect(() => {
|
||||
if (sessionKey === prevKeyRef.current) return;
|
||||
const wasEmpty = !prevKeyRef.current;
|
||||
prevKeyRef.current = sessionKey;
|
||||
if (wasEmpty) return; // new-chat send flow, don't reset
|
||||
|
||||
// "" → newKey: part of new-chat send flow. run.started may have already
|
||||
// captured runIdRef before this effect fires. Don't reset run state —
|
||||
// there's no prior session state to clean up from an empty session.
|
||||
if (wasEmpty) return;
|
||||
setStreamText(null); setThinkingText(null); setToolStream([]);
|
||||
setIsRunning(false); setActivity(null); setBlockReplies([]); setTeamTasks([]);
|
||||
runIdRef.current = null; expectingRunRef.current = false;
|
||||
streamRef.current = ""; thinkingRef.current = "";
|
||||
toolStreamRef.current = []; activityRef.current = null; blockRepliesRef.current = [];
|
||||
cancelAnimationFrame(rafHandleRef.current); rafPendingRef.current = false;
|
||||
if (!sessionKey) setMessages([]);
|
||||
}, [sessionKey, setTeamTasks]);
|
||||
|
||||
// Session-to-session switch: full reset
|
||||
setStreamText(null);
|
||||
setThinkingText(null);
|
||||
setToolStream([]);
|
||||
setIsRunning(false);
|
||||
setActivity(null);
|
||||
setBlockReplies([]);
|
||||
setTeamTasks([]);
|
||||
runIdRef.current = null;
|
||||
expectingRunRef.current = false;
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
toolStreamRef.current = [];
|
||||
activityRef.current = null;
|
||||
blockRepliesRef.current = [];
|
||||
cancelAnimationFrame(rafHandleRef.current);
|
||||
rafPendingRef.current = false;
|
||||
// Clear messages when navigating away from a session (empty key).
|
||||
// When switching to another session, loadHistory() will replace them.
|
||||
if (!sessionKey) {
|
||||
setMessages([]);
|
||||
}
|
||||
}, [sessionKey]);
|
||||
|
||||
// Load history (no loading spinner — the empty state placeholder is shown instead)
|
||||
// Load history
|
||||
const loadHistory = useCallback(async (mediaItems?: MediaItem[]) => {
|
||||
if (!ws.isConnected || !sessionKey) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!ws.isConnected || !sessionKey) { setLoading(false); return; }
|
||||
try {
|
||||
const res = await ws.call<{ messages: Message[] }>(Methods.CHAT_HISTORY, {
|
||||
agentId,
|
||||
sessionKey,
|
||||
});
|
||||
const allMsgs = res.messages ?? [];
|
||||
// Build a map of tool_call_id -> tool message for result lookup
|
||||
const toolResultMap = new Map<string, Message>();
|
||||
for (const m of allMsgs) {
|
||||
if (m.role === "tool" && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, m);
|
||||
}
|
||||
}
|
||||
const msgs: ChatMessage[] = allMsgs.map((m: Message, i: number) => {
|
||||
const chatMsg: ChatMessage = {
|
||||
...m,
|
||||
timestamp: messageToTimestamp(m, i, allMsgs.length),
|
||||
};
|
||||
// Convert persisted media_refs to mediaItems for gallery display
|
||||
if (m.role === "assistant" && m.media_refs && m.media_refs.length > 0) {
|
||||
chatMsg.mediaItems = m.media_refs.map((ref) => ({
|
||||
path: toFileUrl(ref.path || ref.id),
|
||||
mimeType: ref.mime_type,
|
||||
fileName: (ref.path?.split("?")[0]?.split("/").pop()) ?? ref.id,
|
||||
kind: (ref.kind as MediaItem["kind"]) || "document",
|
||||
}));
|
||||
}
|
||||
// Reconstruct toolDetails for assistant messages with tool_calls
|
||||
if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) {
|
||||
chatMsg.toolDetails = m.tool_calls.map((tc) => {
|
||||
const toolMsg = toolResultMap.get(tc.id);
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
runId: "",
|
||||
name: tc.name,
|
||||
phase: (toolMsg ? (toolMsg.is_error ? "error" : "completed") : "calling") as ToolStreamEntry["phase"],
|
||||
startedAt: 0,
|
||||
updatedAt: 0,
|
||||
arguments: tc.arguments,
|
||||
result: toolMsg?.content,
|
||||
};
|
||||
});
|
||||
}
|
||||
return chatMsg;
|
||||
});
|
||||
// Attach media to the last assistant message if provided
|
||||
if (mediaItems?.length && msgs.length > 0) {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i]!.role === "assistant") {
|
||||
msgs[i] = { ...msgs[i]!, mediaItems };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setMessages(msgs);
|
||||
} catch {
|
||||
// will retry
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const res = await ws.call<{ messages: Message[] }>(Methods.CHAT_HISTORY, { agentId, sessionKey });
|
||||
setMessages(transformHistoryMessages(res.messages ?? [], mediaItems));
|
||||
} catch { /* will retry */ } finally { setLoading(false); }
|
||||
}, [ws, agentId, sessionKey]);
|
||||
|
||||
// Load history, restore running state and active tasks when session changes.
|
||||
// Cancelled flag prevents stale RPC responses from overwriting state on rapid switches.
|
||||
// Load history + restore running state when session changes
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (sessionKey) {
|
||||
loadHistory();
|
||||
// Restore session running state (including runId for event filtering)
|
||||
ws.call<{ isRunning?: boolean; runId?: string; activity?: RunActivity }>(Methods.CHAT_SESSION_STATUS, { sessionKey })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.isRunning) {
|
||||
setIsRunning(true);
|
||||
if (res.runId) runIdRef.current = res.runId;
|
||||
}
|
||||
if (res.activity) {
|
||||
setActivity(res.activity);
|
||||
activityRef.current = res.activity;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// Restore active team tasks (teams module may not be configured)
|
||||
if (res.isRunning) { setIsRunning(true); if (res.runId) runIdRef.current = res.runId; }
|
||||
if (res.activity) { setActivity(res.activity); activityRef.current = res.activity; }
|
||||
}).catch(() => {});
|
||||
ws.call<{ tasks?: ActiveTeamTask[] }>(Methods.TEAMS_TASK_ACTIVE_BY_SESSION, { sessionKey })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.tasks && res.tasks.length > 0) setTeamTasks(res.tasks);
|
||||
})
|
||||
.catch(() => {});
|
||||
.then((res) => { if (!cancelled && res.tasks?.length) setTeamTasks(res.tasks); }).catch(() => {});
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionKey, loadHistory, ws]);
|
||||
}, [sessionKey, loadHistory, ws, setTeamTasks]);
|
||||
|
||||
// Called before sending a message so the event handler knows to capture run.started
|
||||
const expectRun = useCallback(() => {
|
||||
expectingRunRef.current = true;
|
||||
}, []);
|
||||
// Called before sending so event handler captures run.started
|
||||
const expectRun = useCallback(() => { expectingRunRef.current = true; }, []);
|
||||
|
||||
// Stable event handler using refs for mutable state
|
||||
// Agent event handler
|
||||
const handleAgentEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
const event = payload as AgentEventPayload;
|
||||
if (!event) return;
|
||||
if (event.channel && event.channel !== "ws" && !event.runKind) return;
|
||||
if (event.sessionKey && event.sessionKey !== sessionKeyRef.current) return;
|
||||
|
||||
// Only process events from WS channel targeting the active session.
|
||||
// Delegations/announces (runKind set) are allowed regardless of channel.
|
||||
if (event.channel && event.channel !== "ws" && !event.runKind) {
|
||||
return;
|
||||
}
|
||||
if (event.sessionKey && event.sessionKey !== sessionKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture run.started when we are expecting a run for this agent,
|
||||
// OR when an announce run starts (leader summarising team results).
|
||||
// Capture run.started
|
||||
if (event.type === "run.started" && event.agentId === agentIdRef.current) {
|
||||
if (expectingRunRef.current || event.runKind === "announce") {
|
||||
runIdRef.current = event.runId;
|
||||
expectingRunRef.current = false;
|
||||
setIsRunning(true);
|
||||
setStreamText(null);
|
||||
setThinkingText(null);
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
toolStreamRef.current = [];
|
||||
runIdRef.current = event.runId; expectingRunRef.current = false;
|
||||
setIsRunning(true); setStreamText(null); setThinkingText(null); setToolStream([]);
|
||||
streamRef.current = ""; thinkingRef.current = ""; toolStreamRef.current = [];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other events must match the active runId
|
||||
if (!runIdRef.current || event.runId !== runIdRef.current) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "thinking": {
|
||||
const content = event.payload?.content ?? "";
|
||||
thinkingRef.current += content;
|
||||
// Batch state updates: only one setState per animation frame
|
||||
thinkingRef.current += event.payload?.content ?? "";
|
||||
if (!rafPendingRef.current) {
|
||||
rafPendingRef.current = true;
|
||||
rafHandleRef.current = requestAnimationFrame(() => {
|
||||
rafPendingRef.current = false;
|
||||
setThinkingText(thinkingRef.current);
|
||||
setStreamText(streamRef.current);
|
||||
setThinkingText(thinkingRef.current); setStreamText(streamRef.current);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "chunk": {
|
||||
const content = event.payload?.content ?? "";
|
||||
streamRef.current += content;
|
||||
// Batch state updates: only one setState per animation frame
|
||||
streamRef.current += event.payload?.content ?? "";
|
||||
if (!rafPendingRef.current) {
|
||||
rafPendingRef.current = true;
|
||||
rafHandleRef.current = requestAnimationFrame(() => {
|
||||
rafPendingRef.current = false;
|
||||
setStreamText(streamRef.current);
|
||||
setThinkingText(thinkingRef.current);
|
||||
setStreamText(streamRef.current); setThinkingText(thinkingRef.current);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool.call": {
|
||||
const entry: ToolStreamEntry = {
|
||||
toolCallId: event.payload?.id ?? "",
|
||||
runId: event.runId,
|
||||
name: event.payload?.name ?? "tool",
|
||||
arguments: event.payload?.arguments,
|
||||
phase: "calling",
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
toolCallId: event.payload?.id ?? "", runId: event.runId,
|
||||
name: event.payload?.name ?? "tool", arguments: event.payload?.arguments,
|
||||
phase: "calling", startedAt: Date.now(), updatedAt: Date.now(),
|
||||
};
|
||||
toolStreamRef.current = [...toolStreamRef.current, entry];
|
||||
setToolStream(toolStreamRef.current);
|
||||
@@ -265,16 +149,9 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
const isError = event.payload?.is_error;
|
||||
const resultId = event.payload?.id;
|
||||
const now = Date.now();
|
||||
// Update ref first so subsequent tool.call events don't overwrite with stale data.
|
||||
toolStreamRef.current = toolStreamRef.current.map((t) =>
|
||||
t.toolCallId === resultId
|
||||
? {
|
||||
...t,
|
||||
phase: isError ? ("error" as const) : ("completed" as const),
|
||||
errorContent: isError ? event.payload?.content : undefined,
|
||||
result: event.payload?.result,
|
||||
updatedAt: now,
|
||||
}
|
||||
? { ...t, phase: isError ? ("error" as const) : ("completed" as const), errorContent: isError ? event.payload?.content : undefined, result: event.payload?.result, updatedAt: now }
|
||||
: t,
|
||||
);
|
||||
setToolStream(toolStreamRef.current);
|
||||
@@ -283,12 +160,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
case "block.reply": {
|
||||
const content = event.payload?.content ?? "";
|
||||
if (content) {
|
||||
const blockMsg: ChatMessage = {
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
isBlockReply: true,
|
||||
};
|
||||
const blockMsg: ChatMessage = { role: "assistant", content, timestamp: Date.now(), isBlockReply: true };
|
||||
blockRepliesRef.current = [...blockRepliesRef.current, blockMsg];
|
||||
setBlockReplies(blockRepliesRef.current);
|
||||
}
|
||||
@@ -297,129 +169,54 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
case "activity": {
|
||||
const phase = event.payload?.phase as RunActivity["phase"];
|
||||
if (phase) {
|
||||
const newActivity: RunActivity = {
|
||||
phase,
|
||||
tool: event.payload?.tool as string | undefined,
|
||||
tools: event.payload?.tools as string[] | undefined,
|
||||
iteration: event.payload?.iteration as number | undefined,
|
||||
};
|
||||
activityRef.current = newActivity;
|
||||
setActivity(newActivity);
|
||||
const newActivity: RunActivity = { phase, tool: event.payload?.tool as string | undefined, tools: event.payload?.tools as string[] | undefined, iteration: event.payload?.iteration as number | undefined };
|
||||
activityRef.current = newActivity; setActivity(newActivity);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "run.retrying": {
|
||||
const attempt = Number(event.payload?.attempt) || 0;
|
||||
const maxAttempts = Number(event.payload?.maxAttempts) || 0;
|
||||
activityRef.current = {
|
||||
phase: "retrying",
|
||||
retryAttempt: attempt,
|
||||
retryMax: maxAttempts,
|
||||
};
|
||||
activityRef.current = { phase: "retrying", retryAttempt: Number(event.payload?.attempt) || 0, retryMax: Number(event.payload?.maxAttempts) || 0 };
|
||||
setActivity(activityRef.current);
|
||||
break;
|
||||
}
|
||||
case "run.completed": {
|
||||
// Cancel any pending rAF to prevent stale state overwrite
|
||||
cancelAnimationFrame(rafHandleRef.current);
|
||||
rafPendingRef.current = false;
|
||||
|
||||
setIsRunning(false);
|
||||
runIdRef.current = null;
|
||||
|
||||
// If we have streamed text and no tool calls, promote it directly
|
||||
// to avoid a flash caused by loadHistory() replacing the DOM.
|
||||
// When tools were used, reload to get structured tool_calls data.
|
||||
cancelAnimationFrame(rafHandleRef.current); rafPendingRef.current = false;
|
||||
setIsRunning(false); runIdRef.current = null;
|
||||
const hadTools = toolStreamRef.current.length > 0;
|
||||
const streamed = streamRef.current;
|
||||
|
||||
setStreamText(null);
|
||||
setThinkingText(null);
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
toolStreamRef.current = [];
|
||||
activityRef.current = null;
|
||||
setActivity(null);
|
||||
blockRepliesRef.current = [];
|
||||
setBlockReplies([]);
|
||||
|
||||
// Convert media from run.completed event to MediaItem[]
|
||||
setStreamText(null); setThinkingText(null); setToolStream([]);
|
||||
streamRef.current = ""; thinkingRef.current = ""; toolStreamRef.current = [];
|
||||
activityRef.current = null; setActivity(null);
|
||||
blockRepliesRef.current = []; setBlockReplies([]);
|
||||
const rawMedia = event.payload?.media;
|
||||
const mediaItems: MediaItem[] | undefined = rawMedia?.length
|
||||
? rawMedia.map((m) => ({
|
||||
path: toFileUrl(m.path),
|
||||
mimeType: m.content_type ?? "application/octet-stream",
|
||||
fileName: m.path.split("?")[0]?.split("/").pop() ?? "file",
|
||||
size: m.size,
|
||||
kind: mediaKindFromMime(m.content_type ?? ""),
|
||||
}))
|
||||
? rawMedia.map((m) => ({ path: toFileUrl(m.path), mimeType: m.content_type ?? "application/octet-stream", fileName: m.path.split("?")[0]?.split("/").pop() ?? "file", size: m.size, kind: mediaKindFromMime(m.content_type ?? "") }))
|
||||
: undefined;
|
||||
|
||||
if (streamed && !hadTools) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: streamed, timestamp: Date.now(), mediaItems },
|
||||
]);
|
||||
} else {
|
||||
loadHistory(mediaItems);
|
||||
}
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: streamed, timestamp: Date.now(), mediaItems }]);
|
||||
} else { loadHistory(mediaItems); }
|
||||
break;
|
||||
}
|
||||
case "run.failed": {
|
||||
cancelAnimationFrame(rafHandleRef.current);
|
||||
rafPendingRef.current = false;
|
||||
|
||||
setIsRunning(false);
|
||||
runIdRef.current = null;
|
||||
setStreamText(null);
|
||||
setThinkingText(null);
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
activityRef.current = null;
|
||||
setActivity(null);
|
||||
blockRepliesRef.current = [];
|
||||
setBlockReplies([]);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: `Error: ${event.payload?.error ?? "Unknown error"}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
cancelAnimationFrame(rafHandleRef.current); rafPendingRef.current = false;
|
||||
setIsRunning(false); runIdRef.current = null;
|
||||
setStreamText(null); setThinkingText(null); setToolStream([]);
|
||||
streamRef.current = ""; thinkingRef.current = "";
|
||||
activityRef.current = null; setActivity(null);
|
||||
blockRepliesRef.current = []; setBlockReplies([]);
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${event.payload?.error ?? "Unknown error"}`, timestamp: Date.now() }]);
|
||||
break;
|
||||
}
|
||||
// User-initiated cancellation — clear state, preserve partial content.
|
||||
case "run.cancelled": {
|
||||
cancelAnimationFrame(rafHandleRef.current);
|
||||
rafPendingRef.current = false;
|
||||
|
||||
setIsRunning(false);
|
||||
runIdRef.current = null;
|
||||
|
||||
cancelAnimationFrame(rafHandleRef.current); rafPendingRef.current = false;
|
||||
setIsRunning(false); runIdRef.current = null;
|
||||
const streamed = streamRef.current;
|
||||
setStreamText(null);
|
||||
setThinkingText(null);
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
toolStreamRef.current = [];
|
||||
activityRef.current = null;
|
||||
setActivity(null);
|
||||
blockRepliesRef.current = [];
|
||||
setBlockReplies([]);
|
||||
|
||||
// Promote partial streamed text or reload history
|
||||
if (streamed) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: streamed, timestamp: Date.now() },
|
||||
]);
|
||||
} else {
|
||||
loadHistory();
|
||||
}
|
||||
setStreamText(null); setThinkingText(null); setToolStream([]);
|
||||
streamRef.current = ""; thinkingRef.current = ""; toolStreamRef.current = [];
|
||||
activityRef.current = null; setActivity(null);
|
||||
blockRepliesRef.current = []; setBlockReplies([]);
|
||||
if (streamed) { setMessages((prev) => [...prev, { role: "assistant", content: streamed, timestamp: Date.now() }]); }
|
||||
else { loadHistory(); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -429,147 +226,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
|
||||
useWsEvent(Events.AGENT, handleAgentEvent);
|
||||
|
||||
// Team task event handler (curried by event name)
|
||||
const handleTeamTaskEvent = useCallback(
|
||||
(eventName: string) => (payload: unknown) => {
|
||||
const event = payload as {
|
||||
team_id?: string;
|
||||
task_id?: string;
|
||||
task_number?: number;
|
||||
subject?: string;
|
||||
status?: string;
|
||||
owner_agent_key?: string;
|
||||
owner_display_name?: string;
|
||||
progress_percent?: number;
|
||||
progress_step?: string;
|
||||
reason?: string;
|
||||
channel?: string;
|
||||
};
|
||||
if (!event?.team_id) return;
|
||||
|
||||
// Only process team task events from WS channel (matches handleAgentEvent filter).
|
||||
// Prevents channel events (Telegram, Discord, etc.) leaking into web UI chat.
|
||||
if (event.channel && event.channel !== "ws") {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamTasks((prev) => {
|
||||
const existing = prev.find((t) => t.taskId === event.task_id);
|
||||
|
||||
if (eventName === "team.task.dispatched" || eventName === "team.task.assigned") {
|
||||
if (existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id
|
||||
? { ...t, status: event.status ?? "in_progress", ownerAgentKey: event.owner_agent_key, ownerDisplayName: event.owner_display_name }
|
||||
: t,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
taskId: event.task_id ?? "",
|
||||
taskNumber: event.task_number ?? 0,
|
||||
subject: event.subject ?? "",
|
||||
status: event.status ?? "in_progress",
|
||||
ownerAgentKey: event.owner_agent_key,
|
||||
ownerDisplayName: event.owner_display_name,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (eventName === "team.task.progress" && existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id
|
||||
? { ...t, progressPercent: event.progress_percent, progressStep: event.progress_step }
|
||||
: t,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
eventName === "team.task.completed" ||
|
||||
eventName === "team.task.failed" ||
|
||||
eventName === "team.task.cancelled"
|
||||
) {
|
||||
return prev.filter((t) => t.taskId !== event.task_id);
|
||||
}
|
||||
|
||||
// Increment counters for comment/attachment events
|
||||
if (eventName === "team.task.commented" && existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id ? { ...t, commentCount: (t.commentCount ?? 0) + 1 } : t,
|
||||
);
|
||||
}
|
||||
if (eventName === "team.task.attachment_added" && existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id ? { ...t, attachmentCount: (t.attachmentCount ?? 0) + 1 } : t,
|
||||
);
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Add inline notification message for key events
|
||||
if (
|
||||
eventName === "team.task.dispatched" ||
|
||||
eventName === "team.task.completed" ||
|
||||
eventName === "team.task.failed" ||
|
||||
eventName === "team.task.commented" ||
|
||||
eventName === "team.task.attachment_added"
|
||||
) {
|
||||
let icon = "📋";
|
||||
let text = "";
|
||||
if (eventName === "team.task.completed") {
|
||||
icon = "✅";
|
||||
text = `Task #${event.task_number} "${event.subject}" completed`;
|
||||
} else if (eventName === "team.task.failed") {
|
||||
icon = "❌";
|
||||
text = `Task #${event.task_number} "${event.subject}" failed${event.reason ? ": " + event.reason : ""}`;
|
||||
} else if (eventName === "team.task.commented") {
|
||||
icon = "💬";
|
||||
text = `Comment on #${event.task_number} "${event.subject}"`;
|
||||
} else if (eventName === "team.task.attachment_added") {
|
||||
icon = "📎";
|
||||
text = `File attached to #${event.task_number} "${event.subject}"`;
|
||||
} else {
|
||||
icon = "📋";
|
||||
text = `Task #${event.task_number} "${event.subject}" → ${event.owner_display_name || event.owner_agent_key}`;
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: `${icon} ${text}`,
|
||||
timestamp: Date.now(),
|
||||
isNotification: true,
|
||||
notificationType: eventName,
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoize bound handlers so useWsEvent doesn't re-register on every render.
|
||||
const onTaskDispatched = useMemo(() => handleTeamTaskEvent("team.task.dispatched"), [handleTeamTaskEvent]);
|
||||
const onTaskCompleted = useMemo(() => handleTeamTaskEvent("team.task.completed"), [handleTeamTaskEvent]);
|
||||
const onTaskFailed = useMemo(() => handleTeamTaskEvent("team.task.failed"), [handleTeamTaskEvent]);
|
||||
const onTaskCancelled = useMemo(() => handleTeamTaskEvent("team.task.cancelled"), [handleTeamTaskEvent]);
|
||||
const onTaskProgress = useMemo(() => handleTeamTaskEvent("team.task.progress"), [handleTeamTaskEvent]);
|
||||
const onTaskAssigned = useMemo(() => handleTeamTaskEvent("team.task.assigned"), [handleTeamTaskEvent]);
|
||||
const onTaskCommented = useMemo(() => handleTeamTaskEvent("team.task.commented"), [handleTeamTaskEvent]);
|
||||
const onTaskAttached = useMemo(() => handleTeamTaskEvent("team.task.attachment_added"), [handleTeamTaskEvent]);
|
||||
|
||||
useWsEvent(Events.TEAM_TASK_DISPATCHED, onTaskDispatched);
|
||||
useWsEvent(Events.TEAM_TASK_COMPLETED, onTaskCompleted);
|
||||
useWsEvent(Events.TEAM_TASK_FAILED, onTaskFailed);
|
||||
useWsEvent(Events.TEAM_TASK_CANCELLED, onTaskCancelled);
|
||||
useWsEvent(Events.TEAM_TASK_PROGRESS, onTaskProgress);
|
||||
useWsEvent(Events.TEAM_TASK_ASSIGNED, onTaskAssigned);
|
||||
useWsEvent(Events.TEAM_TASK_COMMENTED, onTaskCommented);
|
||||
useWsEvent(Events.TEAM_TASK_ATTACHMENT_ADDED, onTaskAttached);
|
||||
|
||||
// Leader processing: backend emits when announce queue drains (before announce run starts).
|
||||
// Leader processing: backend emits when announce queue drains
|
||||
const handleLeaderProcessing = useCallback((payload: unknown) => {
|
||||
const event = payload as { agentId?: string; tasks?: number };
|
||||
if (event?.agentId === agentIdRef.current) {
|
||||
@@ -579,28 +236,10 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
}, []);
|
||||
useWsEvent(Events.TEAM_LEADER_PROCESSING, handleLeaderProcessing);
|
||||
|
||||
// Add a local message optimistically (shown immediately, replaced on next loadHistory)
|
||||
const addLocalMessage = useCallback((msg: ChatMessage) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
// isBusy: true when main agent is running, team tasks are active,
|
||||
// or leader is processing team results before announce run starts.
|
||||
const isBusy = isRunning || teamTasks.length > 0 || activity?.phase === "leader_processing";
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamText,
|
||||
thinkingText,
|
||||
toolStream,
|
||||
isRunning,
|
||||
isBusy,
|
||||
loading,
|
||||
activity,
|
||||
blockReplies,
|
||||
teamTasks,
|
||||
expectRun,
|
||||
loadHistory,
|
||||
addLocalMessage,
|
||||
messages, streamText, thinkingText, toolStream, isRunning, isBusy,
|
||||
loading, activity, blockReplies, teamTasks, expectRun, loadHistory, addLocalMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Events } from "@/api/protocol";
|
||||
import type { ChatMessage, ActiveTeamTask } from "@/types/chat";
|
||||
|
||||
/**
|
||||
* Manages active team task state and inline notification messages
|
||||
* from team task WebSocket events for a chat session.
|
||||
*/
|
||||
export function useChatTeamTasks(
|
||||
addMessage: (msg: ChatMessage) => void,
|
||||
) {
|
||||
const [teamTasks, setTeamTasks] = useState<ActiveTeamTask[]>([]);
|
||||
|
||||
// Team task event handler (curried by event name)
|
||||
const handleTeamTaskEvent = useCallback(
|
||||
(eventName: string) => (payload: unknown) => {
|
||||
const event = payload as {
|
||||
team_id?: string;
|
||||
task_id?: string;
|
||||
task_number?: number;
|
||||
subject?: string;
|
||||
status?: string;
|
||||
owner_agent_key?: string;
|
||||
owner_display_name?: string;
|
||||
progress_percent?: number;
|
||||
progress_step?: string;
|
||||
reason?: string;
|
||||
channel?: string;
|
||||
};
|
||||
if (!event?.team_id) return;
|
||||
|
||||
// Only process team task events from WS channel
|
||||
if (event.channel && event.channel !== "ws") return;
|
||||
|
||||
setTeamTasks((prev) => {
|
||||
const existing = prev.find((t) => t.taskId === event.task_id);
|
||||
|
||||
if (eventName === "team.task.dispatched" || eventName === "team.task.assigned") {
|
||||
if (existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id
|
||||
? { ...t, status: event.status ?? "in_progress", ownerAgentKey: event.owner_agent_key, ownerDisplayName: event.owner_display_name }
|
||||
: t,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
taskId: event.task_id ?? "",
|
||||
taskNumber: event.task_number ?? 0,
|
||||
subject: event.subject ?? "",
|
||||
status: event.status ?? "in_progress",
|
||||
ownerAgentKey: event.owner_agent_key,
|
||||
ownerDisplayName: event.owner_display_name,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (eventName === "team.task.progress" && existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id
|
||||
? { ...t, progressPercent: event.progress_percent, progressStep: event.progress_step }
|
||||
: t,
|
||||
);
|
||||
}
|
||||
|
||||
if (eventName === "team.task.completed" || eventName === "team.task.failed" || eventName === "team.task.cancelled") {
|
||||
return prev.filter((t) => t.taskId !== event.task_id);
|
||||
}
|
||||
|
||||
if (eventName === "team.task.commented" && existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id ? { ...t, commentCount: (t.commentCount ?? 0) + 1 } : t,
|
||||
);
|
||||
}
|
||||
if (eventName === "team.task.attachment_added" && existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id ? { ...t, attachmentCount: (t.attachmentCount ?? 0) + 1 } : t,
|
||||
);
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Add inline notification message for key events
|
||||
if (
|
||||
eventName === "team.task.dispatched" ||
|
||||
eventName === "team.task.completed" ||
|
||||
eventName === "team.task.failed" ||
|
||||
eventName === "team.task.commented" ||
|
||||
eventName === "team.task.attachment_added"
|
||||
) {
|
||||
let icon = "📋";
|
||||
let text = "";
|
||||
if (eventName === "team.task.completed") { icon = "✅"; text = `Task #${event.task_number} "${event.subject}" completed`; }
|
||||
else if (eventName === "team.task.failed") { icon = "❌"; text = `Task #${event.task_number} "${event.subject}" failed${event.reason ? ": " + event.reason : ""}`; }
|
||||
else if (eventName === "team.task.commented") { icon = "💬"; text = `Comment on #${event.task_number} "${event.subject}"`; }
|
||||
else if (eventName === "team.task.attachment_added") { icon = "📎"; text = `File attached to #${event.task_number} "${event.subject}"`; }
|
||||
else { icon = "📋"; text = `Task #${event.task_number} "${event.subject}" → ${event.owner_display_name || event.owner_agent_key}`; }
|
||||
|
||||
addMessage({
|
||||
role: "assistant" as const,
|
||||
content: `${icon} ${text}`,
|
||||
timestamp: Date.now(),
|
||||
isNotification: true,
|
||||
notificationType: eventName,
|
||||
});
|
||||
}
|
||||
},
|
||||
[addMessage],
|
||||
);
|
||||
|
||||
// Memoize bound handlers so useWsEvent doesn't re-register on every render
|
||||
const onTaskDispatched = useMemo(() => handleTeamTaskEvent("team.task.dispatched"), [handleTeamTaskEvent]);
|
||||
const onTaskCompleted = useMemo(() => handleTeamTaskEvent("team.task.completed"), [handleTeamTaskEvent]);
|
||||
const onTaskFailed = useMemo(() => handleTeamTaskEvent("team.task.failed"), [handleTeamTaskEvent]);
|
||||
const onTaskCancelled = useMemo(() => handleTeamTaskEvent("team.task.cancelled"), [handleTeamTaskEvent]);
|
||||
const onTaskProgress = useMemo(() => handleTeamTaskEvent("team.task.progress"), [handleTeamTaskEvent]);
|
||||
const onTaskAssigned = useMemo(() => handleTeamTaskEvent("team.task.assigned"), [handleTeamTaskEvent]);
|
||||
const onTaskCommented = useMemo(() => handleTeamTaskEvent("team.task.commented"), [handleTeamTaskEvent]);
|
||||
const onTaskAttached = useMemo(() => handleTeamTaskEvent("team.task.attachment_added"), [handleTeamTaskEvent]);
|
||||
|
||||
useWsEvent(Events.TEAM_TASK_DISPATCHED, onTaskDispatched);
|
||||
useWsEvent(Events.TEAM_TASK_COMPLETED, onTaskCompleted);
|
||||
useWsEvent(Events.TEAM_TASK_FAILED, onTaskFailed);
|
||||
useWsEvent(Events.TEAM_TASK_CANCELLED, onTaskCancelled);
|
||||
useWsEvent(Events.TEAM_TASK_PROGRESS, onTaskProgress);
|
||||
useWsEvent(Events.TEAM_TASK_ASSIGNED, onTaskAssigned);
|
||||
useWsEvent(Events.TEAM_TASK_COMMENTED, onTaskCommented);
|
||||
useWsEvent(Events.TEAM_TASK_ATTACHMENT_ADDED, onTaskAttached);
|
||||
|
||||
return { teamTasks, setTeamTasks };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { CLIPreset } from "./hooks/use-cli-credentials";
|
||||
|
||||
export interface ManualEnvEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface CliCredentialEnvVarsSectionProps {
|
||||
isManualMode: boolean;
|
||||
activePreset: CLIPreset | null;
|
||||
envValues: Record<string, string>;
|
||||
setEnvValues: (updater: (prev: Record<string, string>) => Record<string, string>) => void;
|
||||
manualEnvEntries: ManualEnvEntry[];
|
||||
setManualEnvEntries: (updater: (prev: ManualEnvEntry[]) => ManualEnvEntry[]) => void;
|
||||
}
|
||||
|
||||
/** Env var inputs: preset-driven fields or free-form key/value pairs in manual mode. */
|
||||
export function CliCredentialEnvVarsSection({
|
||||
isManualMode,
|
||||
activePreset,
|
||||
envValues,
|
||||
setEnvValues,
|
||||
manualEnvEntries,
|
||||
setManualEnvEntries,
|
||||
}: CliCredentialEnvVarsSectionProps) {
|
||||
const { t } = useTranslation("cli-credentials");
|
||||
const { t: tc } = useTranslation("common");
|
||||
|
||||
const addEntry = useCallback(() => {
|
||||
setManualEnvEntries((prev) => [...prev, { key: "", value: "" }]);
|
||||
}, [setManualEnvEntries]);
|
||||
|
||||
const removeEntry = useCallback((index: number) => {
|
||||
setManualEnvEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
}, [setManualEnvEntries]);
|
||||
|
||||
const updateEntry = useCallback((index: number, field: "key" | "value", val: string) => {
|
||||
setManualEnvEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, [field]: val } : entry)),
|
||||
);
|
||||
}, [setManualEnvEntries]);
|
||||
|
||||
// Preset-driven env var inputs
|
||||
if (!isManualMode && activePreset && activePreset.env_vars.length > 0) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border p-3">
|
||||
<p className="text-sm font-medium">{t("form.envVars")}</p>
|
||||
{activePreset.env_vars.map((ev) => (
|
||||
<div key={ev.name} className="grid gap-1.5">
|
||||
<Label htmlFor={`env-${ev.name}`}>
|
||||
{ev.name}
|
||||
{ev.optional && <span className="ml-1 text-xs text-muted-foreground">({tc("optional")})</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={`env-${ev.name}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={ev.desc}
|
||||
value={envValues[ev.name] ?? ""}
|
||||
onChange={(e) => setEnvValues((prev) => ({ ...prev, [ev.name]: e.target.value }))}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
{ev.desc && <p className="text-xs text-muted-foreground">{ev.desc}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Manual free-form key/value entries
|
||||
if (isManualMode) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{t("form.envVars")}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addEntry}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
{t("form.addEnvVar")}
|
||||
</Button>
|
||||
</div>
|
||||
{manualEnvEntries.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t("form.noEnvVarsHint")}</p>
|
||||
)}
|
||||
{manualEnvEntries.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Input
|
||||
placeholder={t("form.envKeyPlaceholder")}
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEntry(idx, "key", e.target.value)}
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={t("form.envValuePlaceholder")}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(idx, "value", e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 h-8 w-8 shrink-0"
|
||||
onClick={() => removeEntry(idx)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ManualEnvEntry } from "./cli-credential-env-vars-section";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
@@ -11,15 +12,12 @@ import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, X, Search, Check, AlertCircle } from "lucide-react";
|
||||
import { Search, Check, AlertCircle } from "lucide-react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "./hooks/use-cli-credentials";
|
||||
import { CliCredentialEnvVarsSection } from "./cli-credential-env-vars-section";
|
||||
|
||||
interface ManualEnvEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -31,6 +29,7 @@ interface Props {
|
||||
|
||||
const NONE_PRESET = "__none__";
|
||||
const GLOBAL_AGENT = "__global__";
|
||||
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export function CliCredentialFormDialog({ open, onOpenChange, credential, presets, onSubmit }: Props) {
|
||||
const { t } = useTranslation("cli-credentials");
|
||||
@@ -50,12 +49,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||
const [manualEnvEntries, setManualEnvEntries] = useState<ManualEnvEntry[]>([]);
|
||||
/** Snapshot when dialog opens (edit) — detect clearing all env rows */
|
||||
const [initialEnvKeys, setInitialEnvKeys] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Check binary state
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<{ found: boolean; path?: string; error?: string } | null>(null);
|
||||
|
||||
@@ -63,10 +59,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
const presetEntries: Array<[string, CLIPreset]> = Object.entries(presets).filter(
|
||||
(e): e is [string, CLIPreset] => e[1] !== undefined,
|
||||
);
|
||||
|
||||
const activePreset: CLIPreset | null =
|
||||
selectedPreset !== NONE_PRESET ? (presets[selectedPreset] ?? null) : null;
|
||||
|
||||
const activePreset: CLIPreset | null = selectedPreset !== NONE_PRESET ? (presets[selectedPreset] ?? null) : null;
|
||||
const isManualMode = selectedPreset === NONE_PRESET;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,9 +104,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
if (!cancelled) applyEnvKeys([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
return () => { cancelled = true; };
|
||||
}, [open, credential, http]);
|
||||
|
||||
const applyPreset = (key: string) => {
|
||||
@@ -142,9 +133,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
{ binary_name: name },
|
||||
);
|
||||
setCheckResult(res);
|
||||
if (res.found && res.path) {
|
||||
setBinaryPath(res.path);
|
||||
}
|
||||
if (res.found && res.path) setBinaryPath(res.path);
|
||||
} catch {
|
||||
setCheckResult({ found: false, error: t("form.binaryNotFound") });
|
||||
} finally {
|
||||
@@ -152,25 +141,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
}
|
||||
};
|
||||
|
||||
const addManualEnvEntry = useCallback(() => {
|
||||
setManualEnvEntries((prev) => [...prev, { key: "", value: "" }]);
|
||||
}, []);
|
||||
|
||||
const removeManualEnvEntry = useCallback((index: number) => {
|
||||
setManualEnvEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const updateManualEnvEntry = useCallback((index: number, field: "key" | "value", val: string) => {
|
||||
setManualEnvEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, [field]: val } : entry)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const splitCommaList = (v: string): string[] =>
|
||||
v.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
const buildEnvPayload = (): Record<string, string> | null => {
|
||||
if (!isManualMode) return envValues;
|
||||
const env: Record<string, string> = {};
|
||||
@@ -186,10 +159,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!binaryName.trim()) {
|
||||
setError(t("form.binaryNameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!binaryName.trim()) { setError(t("form.binaryNameRequired")); return; }
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -246,92 +216,24 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("form.presetHint")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("form.presetHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing credentials indicator (edit mode) */}
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground rounded-md border border-dashed p-2">
|
||||
{t("form.encryptedHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Env var inputs from preset */}
|
||||
{activePreset && activePreset.env_vars.length > 0 && (
|
||||
<div className="grid gap-3 rounded-md border p-3">
|
||||
<p className="text-sm font-medium">{t("form.envVars")}</p>
|
||||
{activePreset.env_vars.map((ev) => (
|
||||
<div key={ev.name} className="grid gap-1.5">
|
||||
<Label htmlFor={`env-${ev.name}`}>
|
||||
{ev.name}
|
||||
{ev.optional && <span className="ml-1 text-xs text-muted-foreground">({tc("optional")})</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={`env-${ev.name}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={ev.desc}
|
||||
value={envValues[ev.name] ?? ""}
|
||||
onChange={(e) => setEnvValues((prev) => ({ ...prev, [ev.name]: e.target.value }))}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
{ev.desc && (
|
||||
<p className="text-xs text-muted-foreground">{ev.desc}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual env var entries (no preset selected) */}
|
||||
{isManualMode && (
|
||||
<div className="grid gap-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{t("form.envVars")}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addManualEnvEntry}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
{t("form.addEnvVar")}
|
||||
</Button>
|
||||
</div>
|
||||
{manualEnvEntries.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t("form.noEnvVarsHint")}</p>
|
||||
)}
|
||||
{manualEnvEntries.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Input
|
||||
placeholder={t("form.envKeyPlaceholder")}
|
||||
value={entry.key}
|
||||
onChange={(e) => updateManualEnvEntry(idx, "key", e.target.value)}
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={t("form.envValuePlaceholder")}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateManualEnvEntry(idx, "value", e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 h-8 w-8 shrink-0"
|
||||
onClick={() => removeManualEnvEntry(idx)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<CliCredentialEnvVarsSection
|
||||
isManualMode={isManualMode}
|
||||
activePreset={activePreset}
|
||||
envValues={envValues}
|
||||
setEnvValues={setEnvValues}
|
||||
manualEnvEntries={manualEnvEntries}
|
||||
setManualEnvEntries={setManualEnvEntries}
|
||||
/>
|
||||
|
||||
{/* Binary name + check button */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@@ -360,13 +262,17 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
{checkResult && (
|
||||
<p className={`text-xs flex items-center gap-1 ${checkResult.found ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
|
||||
{checkResult.found ? <Check className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||
{checkResult.found ? t("form.binaryFound", { path: checkResult.path }) : (checkResult.error || t("form.binaryNotFound"))}
|
||||
{checkResult.found
|
||||
? t("form.binaryFound", { path: checkResult.path })
|
||||
: (checkResult.error || t("form.binaryNotFound"))}
|
||||
</p>
|
||||
)}
|
||||
{checking && <p className="text-xs text-muted-foreground">{t("form.checking")}</p>}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="cc-path">{t("form.binaryPath")} <span className="text-xs text-muted-foreground">({tc("optional")})</span></Label>
|
||||
<Label htmlFor="cc-path">
|
||||
{t("form.binaryPath")} <span className="text-xs text-muted-foreground">({tc("optional")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="cc-path"
|
||||
value={binaryPath}
|
||||
@@ -392,7 +298,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="cc-deny-args">{t("form.denyArgs")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span></Label>
|
||||
<Label htmlFor="cc-deny-args">
|
||||
{t("form.denyArgs")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="cc-deny-args"
|
||||
value={denyArgs}
|
||||
@@ -415,7 +323,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="cc-deny-verbose">{t("form.denyVerbose")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span></Label>
|
||||
<Label htmlFor="cc-deny-verbose">
|
||||
{t("form.denyVerbose")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="cc-deny-verbose"
|
||||
value={denyVerbose}
|
||||
@@ -437,9 +347,10 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent selector */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("form.agentId")} <span className="text-xs text-muted-foreground">({t("form.agentIdHint")})</span></Label>
|
||||
<Label>
|
||||
{t("form.agentId")} <span className="text-xs text-muted-foreground">({t("form.agentIdHint")})</span>
|
||||
</Label>
|
||||
<Select value={agentId || GLOBAL_AGENT} onValueChange={(v) => setAgentId(v === GLOBAL_AGENT ? "" : v)}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("placeholders.agentId")} />
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Reusable form controls for the AI defaults section:
|
||||
* - SubSection: collapsible group panel with title and description
|
||||
* - Field: labeled input with optional InfoLabel tooltip
|
||||
*/
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function SubSection({
|
||||
title,
|
||||
desc,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<div>
|
||||
<span className="font-medium">{title}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{desc}</span>
|
||||
</div>
|
||||
</button>
|
||||
{open && <div className="space-y-3 border-t px-4 py-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
tip,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
step,
|
||||
}: {
|
||||
label: string;
|
||||
tip?: string;
|
||||
value: any;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
step?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
{tip ? <InfoLabel tip={tip}>{label}</InfoLabel> : <Label>{label}</Label>}
|
||||
<Input
|
||||
type={type}
|
||||
step={step}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Save, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { Save, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
import { SubSection, Field } from "./ai-defaults-form-controls";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type AgentsData = Record<string, any>;
|
||||
@@ -263,66 +264,3 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Helper components --- */
|
||||
|
||||
function SubSection({
|
||||
title,
|
||||
desc,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<div>
|
||||
<span className="font-medium">{title}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{desc}</span>
|
||||
</div>
|
||||
</button>
|
||||
{open && <div className="space-y-3 border-t px-4 py-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
tip,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
step,
|
||||
}: {
|
||||
label: string;
|
||||
tip?: string;
|
||||
value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
step?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
{tip ? <InfoLabel tip={tip}>{label}</InfoLabel> : <Label>{label}</Label>}
|
||||
<Input
|
||||
type={type}
|
||||
step={step}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
|
||||
export interface QuotaWindow {
|
||||
hour?: number;
|
||||
day?: number;
|
||||
week?: number;
|
||||
}
|
||||
|
||||
interface QuotaWindowInputsProps {
|
||||
value: QuotaWindow;
|
||||
onChange: (v: QuotaWindow) => void;
|
||||
}
|
||||
|
||||
export function QuotaWindowInputs({ value, onChange }: QuotaWindowInputsProps) {
|
||||
const { t } = useTranslation("config");
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("quota.hourTip")}>{t("quota.hour")}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.hour ?? 0}
|
||||
onChange={(e) => onChange({ ...value, hour: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("quota.dayTip")}>{t("quota.day")}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.day ?? 0}
|
||||
onChange={(e) => onChange({ ...value, day: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("quota.weekTip")}>{t("quota.week")}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.week ?? 0}
|
||||
onChange={(e) => onChange({ ...value, week: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverridesTableProps {
|
||||
label: string;
|
||||
tip: string;
|
||||
entries: Record<string, QuotaWindow>;
|
||||
onChange: (v: Record<string, QuotaWindow>) => void;
|
||||
keyPlaceholder: string;
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export function OverridesTable({
|
||||
label,
|
||||
tip,
|
||||
entries,
|
||||
onChange,
|
||||
keyPlaceholder,
|
||||
options,
|
||||
}: OverridesTableProps) {
|
||||
const { t } = useTranslation("config");
|
||||
const keys = Object.keys(entries);
|
||||
const usedKeys = new Set(keys);
|
||||
const availableOptions = options?.filter((o) => !usedKeys.has(o.value));
|
||||
|
||||
const addRow = (key?: string) => {
|
||||
const newKey = key ?? "";
|
||||
if (newKey in entries) return;
|
||||
onChange({ ...entries, [newKey]: { hour: 0, day: 0, week: 0 } });
|
||||
};
|
||||
|
||||
const removeRow = (key: string) => {
|
||||
const next = { ...entries };
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateKey = (oldKey: string, newKey: string) => {
|
||||
if (newKey !== oldKey && newKey in entries) return;
|
||||
const next: Record<string, QuotaWindow> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
next[k === oldKey ? newKey : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateWindow = (key: string, window: QuotaWindow) => {
|
||||
onChange({ ...entries, [key]: window });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip={tip}>{label}</InfoLabel>
|
||||
{keys.map((key, i) => (
|
||||
<div key={i} className="overflow-x-auto">
|
||||
<div className="flex items-end gap-2 min-w-[420px]">
|
||||
<div className="grid gap-1.5 min-w-[180px]">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t("quota.keyLabel")}</span>
|
||||
)}
|
||||
{options ? (
|
||||
<Select value={key} onValueChange={(v) => updateKey(key, v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={keyPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{key && (
|
||||
<SelectItem value={key}>
|
||||
{options.find((o) => o.value === key)?.label ?? key}
|
||||
</SelectItem>
|
||||
)}
|
||||
{availableOptions
|
||||
?.filter((o) => o.value !== key)
|
||||
.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={keyPlaceholder}
|
||||
value={key}
|
||||
onChange={(e) => updateKey(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t("quota.hour")}</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.hour ?? 0}
|
||||
onChange={(e) => updateWindow(key, { ...entries[key], hour: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t("quota.day")}</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.day ?? 0}
|
||||
onChange={(e) => updateWindow(key, { ...entries[key], day: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t("quota.week")}</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.week ?? 0}
|
||||
onChange={(e) => updateWindow(key, { ...entries[key], week: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => removeRow(key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{options ? (
|
||||
<Select value="" onValueChange={(v) => addRow(v)}>
|
||||
<SelectTrigger className="w-auto gap-1.5" size="sm">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<SelectValue placeholder={t("quota.addOverride")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOptions && availableOptions.length > 0 ? (
|
||||
availableOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="__none__" disabled>
|
||||
{t("quota.allOptionsAdded")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => addRow()} className="gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" /> {t("quota.addOverride")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Save, Plus, Trash2 } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -21,12 +13,7 @@ import {
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances";
|
||||
|
||||
interface QuotaWindow {
|
||||
hour?: number;
|
||||
day?: number;
|
||||
week?: number;
|
||||
}
|
||||
import { QuotaWindowInputs, OverridesTable, type QuotaWindow } from "./quota-overrides-table";
|
||||
|
||||
interface QuotaData {
|
||||
enabled: boolean;
|
||||
@@ -47,225 +34,6 @@ interface Props {
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
function QuotaWindowInputs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: QuotaWindow;
|
||||
onChange: (v: QuotaWindow) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("config");
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("quota.hourTip")}>{t("quota.hour")}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.hour ?? 0}
|
||||
onChange={(e) => onChange({ ...value, hour: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("quota.dayTip")}>{t("quota.day")}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.day ?? 0}
|
||||
onChange={(e) => onChange({ ...value, day: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("quota.weekTip")}>{t("quota.week")}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.week ?? 0}
|
||||
onChange={(e) => onChange({ ...value, week: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverridesTable({
|
||||
label,
|
||||
tip,
|
||||
entries,
|
||||
onChange,
|
||||
keyPlaceholder,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
tip: string;
|
||||
entries: Record<string, QuotaWindow>;
|
||||
onChange: (v: Record<string, QuotaWindow>) => void;
|
||||
keyPlaceholder: string;
|
||||
options?: { value: string; label: string }[];
|
||||
}) {
|
||||
const keys = Object.keys(entries);
|
||||
const usedKeys = new Set(keys);
|
||||
const availableOptions = options?.filter((o) => !usedKeys.has(o.value));
|
||||
|
||||
const addRow = (key?: string) => {
|
||||
const newKey = key ?? "";
|
||||
if (newKey in entries) return;
|
||||
onChange({ ...entries, [newKey]: { hour: 0, day: 0, week: 0 } });
|
||||
};
|
||||
|
||||
const removeRow = (key: string) => {
|
||||
const next = { ...entries };
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateKey = (oldKey: string, newKey: string) => {
|
||||
if (newKey !== oldKey && newKey in entries) return;
|
||||
const next: Record<string, QuotaWindow> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
next[k === oldKey ? newKey : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateWindow = (key: string, window: QuotaWindow) => {
|
||||
onChange({ ...entries, [key]: window });
|
||||
};
|
||||
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip={tip}>{label}</InfoLabel>
|
||||
{keys.map((key, i) => (
|
||||
<div key={i} className="overflow-x-auto">
|
||||
<div className="flex items-end gap-2 min-w-[420px]">
|
||||
<div className="grid gap-1.5 min-w-[180px]">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t("quota.keyLabel")}</span>
|
||||
)}
|
||||
{options ? (
|
||||
<Select
|
||||
value={key}
|
||||
onValueChange={(v) => updateKey(key, v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={keyPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Current value always shown */}
|
||||
{key && (
|
||||
<SelectItem value={key}>
|
||||
{options.find((o) => o.value === key)?.label ?? key}
|
||||
</SelectItem>
|
||||
)}
|
||||
{availableOptions
|
||||
?.filter((o) => o.value !== key)
|
||||
.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={keyPlaceholder}
|
||||
value={key}
|
||||
onChange={(e) => updateKey(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t("quota.hour")}</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.hour ?? 0}
|
||||
onChange={(e) =>
|
||||
updateWindow(key, {
|
||||
...entries[key],
|
||||
hour: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t("quota.day")}</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.day ?? 0}
|
||||
onChange={(e) =>
|
||||
updateWindow(key, {
|
||||
...entries[key],
|
||||
day: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t("quota.week")}</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.week ?? 0}
|
||||
onChange={(e) =>
|
||||
updateWindow(key, {
|
||||
...entries[key],
|
||||
week: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => removeRow(key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{options ? (
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(v) => addRow(v)}
|
||||
>
|
||||
<SelectTrigger className="w-auto gap-1.5" size="sm">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<SelectValue placeholder={t("quota.addOverride")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOptions && availableOptions.length > 0 ? (
|
||||
availableOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="__none__" disabled>
|
||||
{t("quota.allOptionsAdded")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => addRow()} className="gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" /> {t("quota.addOverride")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotaSection({ data, onSave, saving }: Props) {
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Trash2, Plus, X, ChevronDownIcon, Pencil } from "lucide-react";
|
||||
import { Trash2, Plus, Pencil } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
import { cn, uniqueId } from "@/lib/utils";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import type { MCPServerData, MCPAgentGrant, MCPToolInfo } from "./hooks/use-mcp";
|
||||
import { ToolMultiSelect } from "./mcp-tool-multi-select";
|
||||
|
||||
interface MCPGrantsDialogProps {
|
||||
open: boolean;
|
||||
@@ -71,12 +71,9 @@ export function MCPGrantsDialog({
|
||||
}
|
||||
}, [open, server.id, onLoadGrants, onLoadTools]);
|
||||
|
||||
// Resolve agent display name from UUID
|
||||
const agentNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const a of agents) {
|
||||
map.set(a.id, a.display_name || a.agent_key);
|
||||
}
|
||||
for (const a of agents) map.set(a.id, a.display_name || a.agent_key);
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
@@ -99,27 +96,17 @@ export function MCPGrantsDialog({
|
||||
const isEditing = editingGrantId !== null;
|
||||
|
||||
const handleGrant = async () => {
|
||||
if (!agentId) {
|
||||
setError(t("grants.agentRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agentId) { setError(t("grants.agentRequired")); return; }
|
||||
const existing = grants.find((g) => g.agent_id === agentId);
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const allow = toolAllow.length > 0 ? toolAllow : undefined;
|
||||
const deny = toolDeny.length > 0 ? toolDeny : undefined;
|
||||
await onGrant(agentId, allow, deny);
|
||||
|
||||
if (existing) {
|
||||
setGrants((prev) =>
|
||||
prev.map((g) =>
|
||||
g.agent_id === agentId
|
||||
? { ...g, tool_allow: allow ?? null, tool_deny: deny ?? null }
|
||||
: g
|
||||
)
|
||||
prev.map((g) => g.agent_id === agentId ? { ...g, tool_allow: allow ?? null, tool_deny: deny ?? null } : g)
|
||||
);
|
||||
} else {
|
||||
setGrants((prev) => [
|
||||
@@ -164,7 +151,7 @@ export function MCPGrantsDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
|
||||
{/* Existing grants */}
|
||||
{/* Existing grants list */}
|
||||
{grants.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("grants.currentGrants")}</Label>
|
||||
@@ -195,16 +182,16 @@ export function MCPGrantsDialog({
|
||||
{hasAllow && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="success" className="text-[10px] px-1.5 py-0">allow</Badge>
|
||||
{grant.tool_allow!.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="font-mono text-[10px] px-1.5 py-0">{t}</Badge>
|
||||
{grant.tool_allow!.map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="font-mono text-[10px] px-1.5 py-0">{tool}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasDeny && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">deny</Badge>
|
||||
{grant.tool_deny!.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="font-mono text-[10px] px-1.5 py-0">{t}</Badge>
|
||||
{grant.tool_deny!.map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="font-mono text-[10px] px-1.5 py-0">{tool}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -288,157 +275,13 @@ export function MCPGrantsDialog({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
{/* Portal target for dropdowns — inside dialog (pointer events), outside overflow (no clipping) */}
|
||||
|
||||
{/* Portal target for dropdowns — inside dialog, outside overflow clipping */}
|
||||
<div ref={portalRef} className="relative" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline multi-select for MCP tool names with dropdown + free-text support.
|
||||
function ToolMultiSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select or type tool names...",
|
||||
portalContainer,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: MCPToolInfo[];
|
||||
placeholder?: string;
|
||||
portalContainer?: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return options
|
||||
.filter((t) => !value.includes(t.name))
|
||||
.filter((t) => !q || t.name.toLowerCase().includes(q) || (t.description ?? "").toLowerCase().includes(q));
|
||||
}, [options, value, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const inContainer = containerRef.current?.contains(target);
|
||||
const inPortal = portalContainer?.current?.contains(target);
|
||||
if (!inContainer && !inPortal) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open, portalContainer]);
|
||||
|
||||
const addTool = (name: string) => {
|
||||
if (!value.includes(name)) onChange([...value, name]);
|
||||
setSearch("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const removeTool = (name: string) => {
|
||||
onChange(value.filter((v) => v !== name));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
const trimmed = search.trim().replace(/,$/, "");
|
||||
if (trimmed) addTool(trimmed);
|
||||
}
|
||||
if (e.key === "Backspace" && !search && value.length > 0) {
|
||||
removeTool(value[value.length - 1]!);
|
||||
}
|
||||
};
|
||||
|
||||
// Portal dropdown: compute position relative to the portal container.
|
||||
// Can't use position:fixed because Radix Dialog's transform creates a new containing block.
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
useLayoutEffect(() => {
|
||||
if (!open || filtered.length === 0 || !containerRef.current || !portalContainer?.current) return;
|
||||
const inputRect = containerRef.current.getBoundingClientRect();
|
||||
const portalRect = portalContainer.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: "absolute",
|
||||
top: inputRect.bottom - portalRect.top + 4,
|
||||
left: inputRect.left - portalRect.left,
|
||||
width: inputRect.width,
|
||||
zIndex: 50,
|
||||
});
|
||||
}, [open, filtered.length, search, value, portalContainer]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 flex min-h-9 flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-2",
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="bg-secondary text-secondary-foreground inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-destructive ml-0.5"
|
||||
onClick={(e) => { e.stopPropagation(); removeTool(name); }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-base md:text-sm outline-none"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
</div>
|
||||
{open && filtered.length > 0 && portalContainer?.current && createPortal(
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
className="bg-popover text-popover-foreground max-h-56 overflow-y-auto rounded-md border p-1 shadow-md"
|
||||
>
|
||||
{filtered.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => addTool(t.name)}
|
||||
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer flex-col items-start rounded-sm px-2 py-1.5 outline-hidden select-none"
|
||||
>
|
||||
<span className="truncate font-mono text-xs">{t.name}</span>
|
||||
{t.description && (
|
||||
<span className="text-muted-foreground truncate text-[11px] w-full text-left">
|
||||
{t.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
portalContainer.current,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, ChevronDownIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MCPToolInfo } from "./hooks/use-mcp";
|
||||
|
||||
interface ToolMultiSelectProps {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: MCPToolInfo[];
|
||||
placeholder?: string;
|
||||
portalContainer?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
/** Multi-select input for MCP tool names with dropdown + free-text/keyboard support. */
|
||||
export function ToolMultiSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select or type tool names...",
|
||||
portalContainer,
|
||||
}: ToolMultiSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return options
|
||||
.filter((t) => !value.includes(t.name))
|
||||
.filter((t) => !q || t.name.toLowerCase().includes(q) || (t.description ?? "").toLowerCase().includes(q));
|
||||
}, [options, value, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const inContainer = containerRef.current?.contains(target);
|
||||
const inPortal = portalContainer?.current?.contains(target);
|
||||
if (!inContainer && !inPortal) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open, portalContainer]);
|
||||
|
||||
const addTool = (name: string) => {
|
||||
if (!value.includes(name)) onChange([...value, name]);
|
||||
setSearch("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const removeTool = (name: string) => {
|
||||
onChange(value.filter((v) => v !== name));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
const trimmed = search.trim().replace(/,$/, "");
|
||||
if (trimmed) addTool(trimmed);
|
||||
}
|
||||
if (e.key === "Backspace" && !search && value.length > 0) {
|
||||
removeTool(value[value.length - 1]!);
|
||||
}
|
||||
};
|
||||
|
||||
// Portal dropdown: position relative to portal container (fixed avoids Radix Dialog transform).
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
useLayoutEffect(() => {
|
||||
if (!open || filtered.length === 0 || !containerRef.current || !portalContainer?.current) return;
|
||||
const inputRect = containerRef.current.getBoundingClientRect();
|
||||
const portalRect = portalContainer.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: "absolute",
|
||||
top: inputRect.bottom - portalRect.top + 4,
|
||||
left: inputRect.left - portalRect.left,
|
||||
width: inputRect.width,
|
||||
zIndex: 50,
|
||||
});
|
||||
}, [open, filtered.length, search, value, portalContainer]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 flex min-h-9 flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-2",
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="bg-secondary text-secondary-foreground inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-destructive ml-0.5"
|
||||
onClick={(e) => { e.stopPropagation(); removeTool(name); }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-base md:text-sm outline-none"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
</div>
|
||||
{open && filtered.length > 0 && portalContainer?.current && createPortal(
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
className="bg-popover text-popover-foreground max-h-56 overflow-y-auto rounded-md border p-1 shadow-md pointer-events-auto"
|
||||
>
|
||||
{filtered.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => addTool(t.name)}
|
||||
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer flex-col items-start rounded-sm px-2 py-1.5 outline-hidden select-none"
|
||||
>
|
||||
<span className="truncate font-mono text-xs">{t.name}</span>
|
||||
{t.description && (
|
||||
<span className="text-muted-foreground truncate text-[11px] w-full text-left">
|
||||
{t.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
portalContainer.current,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
|
||||
interface VerifyResult {
|
||||
export interface VerifyResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
dimensions?: number;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import type { VerifyResult } from "../hooks/use-provider-verify";
|
||||
|
||||
interface ProviderEmbeddingSectionProps {
|
||||
embEnabled: boolean;
|
||||
setEmbEnabled: (v: boolean) => void;
|
||||
embModel: string;
|
||||
setEmbModel: (v: string) => void;
|
||||
embApiBase: string;
|
||||
setEmbApiBase: (v: string) => void;
|
||||
onVerify: () => void;
|
||||
verifying: boolean;
|
||||
verifyResult: VerifyResult | null;
|
||||
}
|
||||
|
||||
export function ProviderEmbeddingSection({
|
||||
embEnabled,
|
||||
setEmbEnabled,
|
||||
embModel,
|
||||
setEmbModel,
|
||||
embApiBase,
|
||||
setEmbApiBase,
|
||||
onVerify,
|
||||
verifying,
|
||||
verifyResult,
|
||||
}: ProviderEmbeddingSectionProps) {
|
||||
const { t } = useTranslation("providers");
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<h3 className="text-sm font-medium">{t("detail.embeddingSection")}</h3>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="embEnabled" className="text-sm font-medium">
|
||||
{t("embedding.enable")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.enableDesc")}</p>
|
||||
</div>
|
||||
<Switch id="embEnabled" checked={embEnabled} onCheckedChange={setEmbEnabled} />
|
||||
</div>
|
||||
|
||||
{embEnabled ? (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embModel">{t("embedding.model")}</Label>
|
||||
<Input
|
||||
id="embModel"
|
||||
value={embModel}
|
||||
onChange={(e) => setEmbModel(e.target.value)}
|
||||
placeholder="text-embedding-3-small"
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("embedding.dimensions")}</Label>
|
||||
<p className="text-sm text-muted-foreground">1536</p>
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.dimensionsHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embApiBase">{t("embedding.apiBase")}</Label>
|
||||
<Input
|
||||
id="embApiBase"
|
||||
value={embApiBase}
|
||||
onChange={(e) => setEmbApiBase(e.target.value)}
|
||||
placeholder={t("embedding.apiBasePlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.apiBaseHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={verifying}
|
||||
onClick={onVerify}
|
||||
>
|
||||
{verifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{t("embedding.verify")}
|
||||
</Button>
|
||||
{verifyResult ? (
|
||||
<span
|
||||
className={`flex items-center gap-1 text-xs ${
|
||||
verifyResult.valid
|
||||
? verifyResult.dimension_mismatch
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{verifyResult.valid ? (
|
||||
<>
|
||||
{verifyResult.dimension_mismatch ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{verifyResult.dimension_mismatch
|
||||
? t("embedding.dimensionsMismatch", { count: verifyResult.dimensions })
|
||||
: `${verifyResult.dimensions} dimensions`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{verifyResult.error || t("embedding.verifyFailed")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ChatGPTOAuthAvailability } from "../hooks/use-chatgpt-oauth-provider-statuses";
|
||||
import type { ChatGPTOAuthRoutingConfig } from "@/types/agent";
|
||||
import { normalizeReasoningEffort, normalizeReasoningFallback } from "@/types/provider";
|
||||
|
||||
// Provider types that don't use API keys
|
||||
export const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]);
|
||||
|
||||
// Provider types that don't support embedding
|
||||
export const NO_EMBEDDING_TYPES = new Set([
|
||||
"claude_cli",
|
||||
"acp",
|
||||
"chatgpt_oauth",
|
||||
"suno",
|
||||
"anthropic_native",
|
||||
]);
|
||||
|
||||
export const SIMPLE_REASONING_LEVELS = new Set(["off", "low", "medium", "high"]);
|
||||
export const ADVANCED_REASONING_LEVELS = ["off", "auto", "none", "minimal", "low", "medium", "high", "xhigh"] as const;
|
||||
export const REASONING_FALLBACKS = ["downgrade", "provider_default", "off"] as const;
|
||||
|
||||
/** Determine provider OAuth availability from status map */
|
||||
export function providerStatus(
|
||||
providerName: string,
|
||||
statusByName: Map<string, { availability: ChatGPTOAuthAvailability }>,
|
||||
enabled?: boolean,
|
||||
): ChatGPTOAuthAvailability {
|
||||
return (
|
||||
statusByName.get(providerName)?.availability ??
|
||||
(enabled === false ? "disabled" : "needs_sign_in")
|
||||
);
|
||||
}
|
||||
|
||||
/** Serialize routing config for dirty-check comparison */
|
||||
export function routingSignature(routing: ChatGPTOAuthRoutingConfig): string {
|
||||
const extras = Array.from(
|
||||
new Set((routing.extra_provider_names ?? []).map((name) => name.trim()).filter(Boolean)),
|
||||
);
|
||||
const strategy =
|
||||
routing.strategy === "round_robin" || routing.strategy === "priority_order"
|
||||
? routing.strategy
|
||||
: "primary_first";
|
||||
return JSON.stringify({ strategy, extra_provider_names: extras });
|
||||
}
|
||||
|
||||
/** Serialize reasoning config for dirty-check comparison */
|
||||
export function reasoningSignature(effort: string, fallback: string): string {
|
||||
return JSON.stringify({
|
||||
effort: normalizeReasoningEffort(effort) || "off",
|
||||
fallback: normalizeReasoningFallback(fallback),
|
||||
});
|
||||
}
|
||||
|
||||
/** Return non-empty API key value for comparison (handles masked "***") */
|
||||
export function comparableAPIKeyValue(
|
||||
apiKey: string,
|
||||
savedAPIKey: string,
|
||||
showApiKey: boolean,
|
||||
): string {
|
||||
if (!showApiKey) return "";
|
||||
if (apiKey === "***") return "";
|
||||
if (apiKey === "" && savedAPIKey === "***") return "";
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/** Build a serializable form signature for dirty-check comparison */
|
||||
export function providerFormSignature(input: {
|
||||
displayName: string;
|
||||
apiKey: string;
|
||||
savedAPIKey: string;
|
||||
showApiKey: boolean;
|
||||
enabled: boolean;
|
||||
embEnabled: boolean;
|
||||
embModel: string;
|
||||
embApiBase: string;
|
||||
routing: ChatGPTOAuthRoutingConfig;
|
||||
reasoningEffort: string;
|
||||
reasoningFallback: string;
|
||||
isOAuth: boolean;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
displayName: input.displayName,
|
||||
apiKey: comparableAPIKeyValue(input.apiKey, input.savedAPIKey, input.showApiKey),
|
||||
enabled: input.enabled,
|
||||
embEnabled: input.embEnabled,
|
||||
embModel: input.embModel,
|
||||
embApiBase: input.embApiBase,
|
||||
routing: input.isOAuth ? routingSignature(input.routing) : "",
|
||||
reasoning: reasoningSignature(input.reasoningEffort, input.reasoningFallback),
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Copy,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,125 +13,45 @@ import { useProviders } from "../hooks/use-providers";
|
||||
import { useProviderModels } from "../hooks/use-provider-models";
|
||||
import { useProviderVerify } from "../hooks/use-provider-verify";
|
||||
import { ProviderOAuthAccountSection } from "./provider-oauth-account-section";
|
||||
import { ProviderReasoningSection } from "./provider-reasoning-section";
|
||||
import { ProviderEmbeddingSection } from "./provider-embedding-section";
|
||||
import { ProviderPoolActivitySection } from "./provider-pool-activity-section";
|
||||
import {
|
||||
buildProviderSettingsWithChatGPTOAuthRouting,
|
||||
buildProviderSettingsWithReasoningDefaults,
|
||||
getChatGPTOAuthProviderRouting,
|
||||
getEmbeddingSettings,
|
||||
getProviderReasoningDefaults,
|
||||
normalizeReasoningEffort,
|
||||
normalizeReasoningFallback,
|
||||
deriveLegacyThinkingLevel,
|
||||
} from "@/types/provider";
|
||||
import type { ProviderData, ProviderInput } from "@/types/provider";
|
||||
import type { ChatGPTOAuthRoutingConfig } from "@/types/agent";
|
||||
import {
|
||||
useChatGPTOAuthProviderStatuses,
|
||||
type ChatGPTOAuthAvailability,
|
||||
} from "../hooks/use-chatgpt-oauth-provider-statuses";
|
||||
import { useChatGPTOAuthProviderStatuses } from "../hooks/use-chatgpt-oauth-provider-statuses";
|
||||
import { useChatGPTOAuthProviderQuotas } from "../hooks/use-chatgpt-oauth-provider-quotas";
|
||||
import {
|
||||
ChatGPTOAuthRoutingSection,
|
||||
} from "@/pages/agents/agent-detail/config-sections";
|
||||
import { ChatGPTOAuthRoutingSection } from "@/pages/agents/agent-detail/config-sections";
|
||||
import type { CodexPoolEntry } from "@/pages/agents/agent-detail/codex-pool-activity-panel";
|
||||
import { useProviderCodexPoolActivity } from "../hooks/use-provider-codex-pool-activity";
|
||||
import { ProviderPoolActivitySection } from "./provider-pool-activity-section";
|
||||
import {
|
||||
NO_API_KEY_TYPES,
|
||||
NO_EMBEDDING_TYPES,
|
||||
SIMPLE_REASONING_LEVELS,
|
||||
providerStatus,
|
||||
routingSignature,
|
||||
comparableAPIKeyValue,
|
||||
providerFormSignature,
|
||||
reasoningSignature,
|
||||
} from "./provider-overview-helpers";
|
||||
|
||||
interface ProviderOverviewProps {
|
||||
provider: ProviderData;
|
||||
onUpdate: (id: string, data: ProviderInput) => Promise<void>;
|
||||
}
|
||||
|
||||
const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]);
|
||||
const NO_EMBEDDING_TYPES = new Set([
|
||||
"claude_cli",
|
||||
"acp",
|
||||
"chatgpt_oauth",
|
||||
"suno",
|
||||
"anthropic_native",
|
||||
]);
|
||||
const SIMPLE_REASONING_LEVELS = new Set(["off", "low", "medium", "high"]);
|
||||
const ADVANCED_REASONING_LEVELS = ["off", "auto", "none", "minimal", "low", "medium", "high", "xhigh"] as const;
|
||||
const REASONING_FALLBACKS = ["downgrade", "provider_default", "off"] as const;
|
||||
|
||||
function providerStatus(
|
||||
providerName: string,
|
||||
statusByName: Map<string, { availability: ChatGPTOAuthAvailability }>,
|
||||
enabled?: boolean,
|
||||
): ChatGPTOAuthAvailability {
|
||||
return (
|
||||
statusByName.get(providerName)?.availability ??
|
||||
(enabled === false ? "disabled" : "needs_sign_in")
|
||||
);
|
||||
}
|
||||
|
||||
function routingSignature(routing: ChatGPTOAuthRoutingConfig): string {
|
||||
const extras = Array.from(
|
||||
new Set((routing.extra_provider_names ?? []).map((name) => name.trim()).filter(Boolean)),
|
||||
);
|
||||
const strategy =
|
||||
routing.strategy === "round_robin" || routing.strategy === "priority_order"
|
||||
? routing.strategy
|
||||
: "primary_first";
|
||||
return JSON.stringify({
|
||||
strategy,
|
||||
extra_provider_names: extras,
|
||||
});
|
||||
}
|
||||
|
||||
function comparableAPIKeyValue(
|
||||
apiKey: string,
|
||||
savedAPIKey: string,
|
||||
showApiKey: boolean,
|
||||
): string {
|
||||
if (!showApiKey) return "";
|
||||
if (apiKey === "***") return "";
|
||||
if (apiKey === "" && savedAPIKey === "***") return "";
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function providerFormSignature(input: {
|
||||
displayName: string;
|
||||
apiKey: string;
|
||||
savedAPIKey: string;
|
||||
showApiKey: boolean;
|
||||
enabled: boolean;
|
||||
embEnabled: boolean;
|
||||
embModel: string;
|
||||
embApiBase: string;
|
||||
routing: ChatGPTOAuthRoutingConfig;
|
||||
reasoningEffort: string;
|
||||
reasoningFallback: string;
|
||||
isOAuth: boolean;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
displayName: input.displayName,
|
||||
apiKey: comparableAPIKeyValue(input.apiKey, input.savedAPIKey, input.showApiKey),
|
||||
enabled: input.enabled,
|
||||
embEnabled: input.embEnabled,
|
||||
embModel: input.embModel,
|
||||
embApiBase: input.embApiBase,
|
||||
routing: input.isOAuth ? routingSignature(input.routing) : "",
|
||||
reasoning: reasoningSignature(input.reasoningEffort, input.reasoningFallback),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function reasoningSignature(effort: string, fallback: string): string {
|
||||
return JSON.stringify({
|
||||
effort: normalizeReasoningEffort(effort) || "off",
|
||||
fallback: normalizeReasoningFallback(fallback),
|
||||
});
|
||||
}
|
||||
|
||||
export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) {
|
||||
const { t } = useTranslation("providers");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const { providers } = useProviders();
|
||||
const {
|
||||
models: providerModels,
|
||||
reasoningDefaults: providerReasoningDefaults,
|
||||
} = useProviderModels(provider.id);
|
||||
const { models: providerModels, reasoningDefaults: providerReasoningDefaults } = useProviderModels(provider.id);
|
||||
const { statuses } = useChatGPTOAuthProviderStatuses(providers);
|
||||
|
||||
const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
|
||||
@@ -153,10 +60,8 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
|
||||
const showEmbedding = !NO_EMBEDDING_TYPES.has(provider.provider_type);
|
||||
const isOAuth = provider.provider_type === "chatgpt_oauth";
|
||||
|
||||
const providerByName = useMemo(
|
||||
() => new Map(providers.map((item) => [item.name, item])),
|
||||
[providers],
|
||||
);
|
||||
// --- Pool ownership & status maps ---
|
||||
const providerByName = useMemo(() => new Map(providers.map((item) => [item.name, item])), [providers]);
|
||||
const poolOwnership = useMemo(() => {
|
||||
const membersByOwner = new Map<string, string[]>();
|
||||
const ownerByMember = new Map<string, string>();
|
||||
@@ -166,37 +71,21 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
|
||||
if (!routing || routing.extraProviderNames.length === 0) continue;
|
||||
membersByOwner.set(item.name, routing.extraProviderNames);
|
||||
for (const memberName of routing.extraProviderNames) {
|
||||
if (!ownerByMember.has(memberName)) {
|
||||
ownerByMember.set(memberName, item.name);
|
||||
}
|
||||
if (!ownerByMember.has(memberName)) ownerByMember.set(memberName, item.name);
|
||||
}
|
||||
}
|
||||
return { membersByOwner, ownerByMember };
|
||||
}, [providers]);
|
||||
const statusByName = useMemo(
|
||||
() => new Map(statuses.map((status) => [status.provider.name, status])),
|
||||
[statuses],
|
||||
);
|
||||
const managedByOwnerName = isOAuth
|
||||
? poolOwnership.ownerByMember.get(provider.name)
|
||||
: undefined;
|
||||
const managedByProvider = managedByOwnerName
|
||||
? providerByName.get(managedByOwnerName)
|
||||
: undefined;
|
||||
const managedMemberCount = isOAuth
|
||||
? poolOwnership.membersByOwner.get(provider.name)?.length ?? 0
|
||||
: 0;
|
||||
const statusByName = useMemo(() => new Map(statuses.map((s) => [s.provider.name, s])), [statuses]);
|
||||
const managedByOwnerName = isOAuth ? poolOwnership.ownerByMember.get(provider.name) : undefined;
|
||||
const managedByProvider = managedByOwnerName ? providerByName.get(managedByOwnerName) : undefined;
|
||||
const managedMemberCount = isOAuth ? poolOwnership.membersByOwner.get(provider.name)?.length ?? 0 : 0;
|
||||
const canEditPoolRouting = isOAuth && !managedByOwnerName;
|
||||
const currentOAuthAvailability = providerStatus(
|
||||
provider.name,
|
||||
statusByName,
|
||||
provider.enabled,
|
||||
);
|
||||
const currentOAuthAvailability = providerStatus(provider.name, statusByName, provider.enabled);
|
||||
|
||||
// --- Form state ---
|
||||
const initialRouting = getChatGPTOAuthProviderRouting(provider.settings);
|
||||
const initialReasoningDefaults = getProviderReasoningDefaults(provider.settings)
|
||||
?? providerReasoningDefaults
|
||||
?? null;
|
||||
const initialReasoningDefaults = getProviderReasoningDefaults(provider.settings) ?? providerReasoningDefaults ?? null;
|
||||
const initialReasoningEffort = initialReasoningDefaults?.effort ?? "off";
|
||||
const initialReasoningFallback = initialReasoningDefaults?.fallback ?? "downgrade";
|
||||
|
||||
@@ -207,540 +96,166 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
|
||||
strategy: initialRouting?.strategy ?? "primary_first",
|
||||
extra_provider_names: initialRouting?.extraProviderNames ?? [],
|
||||
});
|
||||
const selectedPoolProviderNames = useMemo(
|
||||
() =>
|
||||
canEditPoolRouting
|
||||
? Array.from(
|
||||
new Set(
|
||||
[provider.name, ...(poolRouting.extra_provider_names ?? [])]
|
||||
.filter(Boolean)
|
||||
.filter((name) => name === provider.name || providerByName.has(name)),
|
||||
),
|
||||
)
|
||||
: [provider.name],
|
||||
[canEditPoolRouting, poolRouting.extra_provider_names, provider.name, providerByName],
|
||||
);
|
||||
|
||||
const initEmb = getEmbeddingSettings(provider.settings);
|
||||
const [embEnabled, setEmbEnabled] = useState(initEmb?.enabled ?? false);
|
||||
const [embModel, setEmbModel] = useState(initEmb?.model ?? "");
|
||||
const [embApiBase, setEmbApiBase] = useState(initEmb?.api_base ?? "");
|
||||
const [reasoningThinkingLevel, setReasoningThinkingLevel] = useState(
|
||||
deriveLegacyThinkingLevel(initialReasoningEffort),
|
||||
);
|
||||
const [reasoningThinkingLevel, setReasoningThinkingLevel] = useState(deriveLegacyThinkingLevel(initialReasoningEffort));
|
||||
const [reasoningEffort, setReasoningEffort] = useState(initialReasoningEffort);
|
||||
const [reasoningFallback, setReasoningFallback] = useState(initialReasoningFallback);
|
||||
const [reasoningExpert, setReasoningExpert] = useState(
|
||||
Boolean(initialReasoningDefaults) && (
|
||||
!SIMPLE_REASONING_LEVELS.has(initialReasoningEffort)
|
||||
|| initialReasoningFallback !== "downgrade"
|
||||
),
|
||||
Boolean(initialReasoningDefaults) && (!SIMPLE_REASONING_LEVELS.has(initialReasoningEffort) || initialReasoningFallback !== "downgrade"),
|
||||
);
|
||||
const [reasoningPreviewModel, setReasoningPreviewModel] = useState("");
|
||||
const syncedProviderIDRef = useRef(provider.id);
|
||||
const reasoningCapableModels = useMemo(
|
||||
() => providerModels.filter((model) => (model.reasoning?.levels?.length ?? 0) > 0),
|
||||
[providerModels],
|
||||
);
|
||||
|
||||
// --- Reasoning model preview ---
|
||||
const reasoningCapableModels = useMemo(() => providerModels.filter((m) => (m.reasoning?.levels?.length ?? 0) > 0), [providerModels]);
|
||||
const reasoningPreviewEntry = useMemo(
|
||||
() => reasoningCapableModels.find((model) => model.id === reasoningPreviewModel)
|
||||
?? reasoningCapableModels[0]
|
||||
?? null,
|
||||
() => reasoningCapableModels.find((m) => m.id === reasoningPreviewModel) ?? reasoningCapableModels[0] ?? null,
|
||||
[reasoningCapableModels, reasoningPreviewModel],
|
||||
);
|
||||
const reasoningPreviewCapability = reasoningPreviewEntry?.reasoning ?? null;
|
||||
const showReasoningDefaults = Boolean(initialReasoningDefaults) || reasoningCapableModels.length > 0;
|
||||
const savedReasoningSignature = useMemo(
|
||||
() => reasoningSignature(
|
||||
initialReasoningDefaults?.effort ?? "off",
|
||||
initialReasoningDefaults?.fallback ?? "downgrade",
|
||||
),
|
||||
[initialReasoningDefaults?.effort, initialReasoningDefaults?.fallback],
|
||||
);
|
||||
const draftReasoningSignature = useMemo(
|
||||
() => reasoningSignature(
|
||||
reasoningExpert ? reasoningEffort : reasoningThinkingLevel,
|
||||
reasoningExpert ? reasoningFallback : "downgrade",
|
||||
),
|
||||
[reasoningEffort, reasoningExpert, reasoningFallback, reasoningThinkingLevel],
|
||||
);
|
||||
|
||||
const savedFormSignature = useMemo(
|
||||
() =>
|
||||
providerFormSignature({
|
||||
displayName: provider.display_name || "",
|
||||
apiKey: provider.api_key || "",
|
||||
savedAPIKey: provider.api_key || "",
|
||||
showApiKey,
|
||||
enabled: provider.enabled,
|
||||
embEnabled: initEmb?.enabled ?? false,
|
||||
embModel: initEmb?.model ?? "",
|
||||
embApiBase: initEmb?.api_base ?? "",
|
||||
routing: {
|
||||
strategy: initialRouting?.strategy ?? "primary_first",
|
||||
extra_provider_names: initialRouting?.extraProviderNames ?? [],
|
||||
},
|
||||
reasoningEffort: initialReasoningDefaults?.effort ?? "off",
|
||||
reasoningFallback: initialReasoningDefaults?.fallback ?? "downgrade",
|
||||
isOAuth,
|
||||
}),
|
||||
// --- Dirty checking ---
|
||||
const savedReasoningSig = useMemo(() => reasoningSignature(initialReasoningDefaults?.effort ?? "off", initialReasoningDefaults?.fallback ?? "downgrade"), [initialReasoningDefaults?.effort, initialReasoningDefaults?.fallback]);
|
||||
const draftReasoningSig = useMemo(() => reasoningSignature(reasoningExpert ? reasoningEffort : reasoningThinkingLevel, reasoningExpert ? reasoningFallback : "downgrade"), [reasoningEffort, reasoningExpert, reasoningFallback, reasoningThinkingLevel]);
|
||||
const savedFormSig = useMemo(
|
||||
() => providerFormSignature({ displayName: provider.display_name || "", apiKey: provider.api_key || "", savedAPIKey: provider.api_key || "", showApiKey, enabled: provider.enabled, embEnabled: initEmb?.enabled ?? false, embModel: initEmb?.model ?? "", embApiBase: initEmb?.api_base ?? "", routing: { strategy: initialRouting?.strategy ?? "primary_first", extra_provider_names: initialRouting?.extraProviderNames ?? [] }, reasoningEffort: initialReasoningDefaults?.effort ?? "off", reasoningFallback: initialReasoningDefaults?.fallback ?? "downgrade", isOAuth }),
|
||||
[initEmb?.api_base, initEmb?.enabled, initEmb?.model, initialReasoningDefaults?.effort, initialReasoningDefaults?.fallback, initialRouting?.extraProviderNames, initialRouting?.strategy, isOAuth, provider.api_key, provider.display_name, provider.enabled, showApiKey],
|
||||
);
|
||||
const savedFormSignatureRef = useRef(savedFormSignature);
|
||||
const draftFormSignature = useMemo(
|
||||
() =>
|
||||
providerFormSignature({
|
||||
displayName,
|
||||
apiKey,
|
||||
savedAPIKey: provider.api_key || "",
|
||||
showApiKey,
|
||||
enabled,
|
||||
embEnabled,
|
||||
embModel,
|
||||
embApiBase,
|
||||
routing: poolRouting,
|
||||
reasoningEffort: reasoningExpert ? reasoningEffort : reasoningThinkingLevel,
|
||||
reasoningFallback: reasoningExpert ? reasoningFallback : "downgrade",
|
||||
isOAuth,
|
||||
}),
|
||||
const savedFormSigRef = useRef(savedFormSig);
|
||||
const draftFormSig = useMemo(
|
||||
() => providerFormSignature({ displayName, apiKey, savedAPIKey: provider.api_key || "", showApiKey, enabled, embEnabled, embModel, embApiBase, routing: poolRouting, reasoningEffort: reasoningExpert ? reasoningEffort : reasoningThinkingLevel, reasoningFallback: reasoningExpert ? reasoningFallback : "downgrade", isOAuth }),
|
||||
[apiKey, displayName, embApiBase, embEnabled, embModel, enabled, isOAuth, poolRouting, provider.api_key, reasoningEffort, reasoningExpert, reasoningFallback, reasoningThinkingLevel, showApiKey],
|
||||
);
|
||||
|
||||
// --- Sync from provider on external update ---
|
||||
useEffect(() => {
|
||||
const nextProviderID = provider.id;
|
||||
const nextID = provider.id;
|
||||
const es = getEmbeddingSettings(provider.settings);
|
||||
const routing = getChatGPTOAuthProviderRouting(provider.settings);
|
||||
const reasoning = getProviderReasoningDefaults(provider.settings) ?? providerReasoningDefaults;
|
||||
const syncFromProvider = () => {
|
||||
setEmbEnabled(es?.enabled ?? false);
|
||||
setEmbModel(es?.model ?? "");
|
||||
setEmbApiBase(es?.api_base ?? "");
|
||||
setPoolRouting({
|
||||
strategy: routing?.strategy ?? "primary_first",
|
||||
extra_provider_names: routing?.extraProviderNames ?? [],
|
||||
});
|
||||
const nextReasoningEffort = reasoning?.effort ?? "off";
|
||||
const nextReasoningFallback = reasoning?.fallback ?? "downgrade";
|
||||
setReasoningEffort(nextReasoningEffort);
|
||||
setReasoningFallback(nextReasoningFallback);
|
||||
setReasoningThinkingLevel(deriveLegacyThinkingLevel(nextReasoningEffort));
|
||||
setReasoningExpert(
|
||||
!SIMPLE_REASONING_LEVELS.has(nextReasoningEffort)
|
||||
|| nextReasoningFallback !== "downgrade",
|
||||
);
|
||||
setDisplayName(provider.display_name || "");
|
||||
setApiKey(provider.api_key || "");
|
||||
setEnabled(provider.enabled);
|
||||
setEmbEnabled(es?.enabled ?? false); setEmbModel(es?.model ?? ""); setEmbApiBase(es?.api_base ?? "");
|
||||
setPoolRouting({ strategy: routing?.strategy ?? "primary_first", extra_provider_names: routing?.extraProviderNames ?? [] });
|
||||
const nextEffort = reasoning?.effort ?? "off"; const nextFallback = reasoning?.fallback ?? "downgrade";
|
||||
setReasoningEffort(nextEffort); setReasoningFallback(nextFallback);
|
||||
setReasoningThinkingLevel(deriveLegacyThinkingLevel(nextEffort));
|
||||
setReasoningExpert(!SIMPLE_REASONING_LEVELS.has(nextEffort) || nextFallback !== "downgrade");
|
||||
setDisplayName(provider.display_name || ""); setApiKey(provider.api_key || ""); setEnabled(provider.enabled);
|
||||
};
|
||||
|
||||
if (nextProviderID !== syncedProviderIDRef.current) {
|
||||
syncedProviderIDRef.current = nextProviderID;
|
||||
savedFormSignatureRef.current = savedFormSignature;
|
||||
syncFromProvider();
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSavedSignature = savedFormSignatureRef.current;
|
||||
if (savedFormSignature === previousSavedSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draftFormSignature === previousSavedSignature) {
|
||||
syncFromProvider();
|
||||
}
|
||||
savedFormSignatureRef.current = savedFormSignature;
|
||||
}, [draftFormSignature, provider.api_key, provider.display_name, provider.enabled, provider.id, provider.settings, providerReasoningDefaults, savedFormSignature]);
|
||||
if (nextID !== syncedProviderIDRef.current) { syncedProviderIDRef.current = nextID; savedFormSigRef.current = savedFormSig; syncFromProvider(); return; }
|
||||
const prev = savedFormSigRef.current;
|
||||
if (savedFormSig === prev) return;
|
||||
if (draftFormSig === prev) syncFromProvider();
|
||||
savedFormSigRef.current = savedFormSig;
|
||||
}, [draftFormSig, provider.api_key, provider.display_name, provider.enabled, provider.id, provider.settings, providerReasoningDefaults, savedFormSig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reasoningCapableModels.length === 0) {
|
||||
if (reasoningPreviewModel !== "") setReasoningPreviewModel("");
|
||||
return;
|
||||
}
|
||||
if (reasoningCapableModels.some((model) => model.id === reasoningPreviewModel)) {
|
||||
return;
|
||||
}
|
||||
if (reasoningCapableModels.length === 0) { if (reasoningPreviewModel !== "") setReasoningPreviewModel(""); return; }
|
||||
if (reasoningCapableModels.some((m) => m.id === reasoningPreviewModel)) return;
|
||||
setReasoningPreviewModel(reasoningCapableModels[0]?.id ?? "");
|
||||
}, [reasoningCapableModels, reasoningPreviewModel]);
|
||||
|
||||
const quotaProviderNames = useMemo(
|
||||
() => {
|
||||
if (!isOAuth) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
selectedPoolProviderNames.filter((providerName) => {
|
||||
if (!providerName) return false;
|
||||
const item = providerByName.get(providerName);
|
||||
return (
|
||||
providerStatus(providerName, statusByName, item?.enabled) === "ready"
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
[
|
||||
isOAuth,
|
||||
providerByName,
|
||||
selectedPoolProviderNames,
|
||||
statusByName,
|
||||
],
|
||||
// --- Pool entries & quota ---
|
||||
const selectedPoolProviderNames = useMemo(
|
||||
() => canEditPoolRouting ? Array.from(new Set([provider.name, ...(poolRouting.extra_provider_names ?? [])].filter(Boolean).filter((n) => n === provider.name || providerByName.has(n)))) : [provider.name],
|
||||
[canEditPoolRouting, poolRouting.extra_provider_names, provider.name, providerByName],
|
||||
);
|
||||
const {
|
||||
quotaByName,
|
||||
isLoading: quotasLoading,
|
||||
isFetching: quotasFetching,
|
||||
} = useChatGPTOAuthProviderQuotas(quotaProviderNames, isOAuth);
|
||||
const quotaProviderNames = useMemo(() => {
|
||||
if (!isOAuth) return [];
|
||||
return Array.from(new Set(selectedPoolProviderNames.filter((n) => { if (!n) return false; const item = providerByName.get(n); return providerStatus(n, statusByName, item?.enabled) === "ready"; })));
|
||||
}, [isOAuth, providerByName, selectedPoolProviderNames, statusByName]);
|
||||
const { quotaByName, isLoading: quotasLoading, isFetching: quotasFetching } = useChatGPTOAuthProviderQuotas(quotaProviderNames, isOAuth);
|
||||
const poolEntries = useMemo<CodexPoolEntry[]>(() => {
|
||||
if (!canEditPoolRouting) return [];
|
||||
return selectedPoolProviderNames.map((providerName) => {
|
||||
const item = providerByName.get(providerName);
|
||||
return {
|
||||
name: providerName,
|
||||
label: item?.display_name || providerName,
|
||||
availability: providerStatus(providerName, statusByName, item?.enabled),
|
||||
role: providerName === provider.name ? "preferred" : "extra",
|
||||
requestCount: 0,
|
||||
directSelectionCount: 0,
|
||||
failoverServeCount: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
consecutiveFailures: 0,
|
||||
successRate: 0,
|
||||
healthScore: 0,
|
||||
healthState: "idle",
|
||||
providerHref: item?.id ? `/providers/${item.id}` : undefined,
|
||||
quota: quotaByName.get(providerName),
|
||||
};
|
||||
return selectedPoolProviderNames.map((n) => {
|
||||
const item = providerByName.get(n);
|
||||
return { name: n, label: item?.display_name || n, availability: providerStatus(n, statusByName, item?.enabled), role: n === provider.name ? "preferred" : "extra", requestCount: 0, directSelectionCount: 0, failoverServeCount: 0, successCount: 0, failureCount: 0, consecutiveFailures: 0, successRate: 0, healthScore: 0, healthState: "idle" as const, providerHref: item?.id ? `/providers/${item.id}` : undefined, quota: quotaByName.get(n) };
|
||||
});
|
||||
}, [canEditPoolRouting, provider.name, providerByName, quotaByName, selectedPoolProviderNames, statusByName]);
|
||||
|
||||
// Provider-scoped pool activity (only for pool owners)
|
||||
const isPoolOwner = canEditPoolRouting && selectedPoolProviderNames.length > 1;
|
||||
const {
|
||||
data: poolActivity,
|
||||
isFetching: poolActivityFetching,
|
||||
refetch: refreshPoolActivity,
|
||||
} = useProviderCodexPoolActivity(provider.id, 8, isPoolOwner);
|
||||
const { data: poolActivity, isFetching: poolActivityFetching, refetch: refreshPoolActivity } = useProviderCodexPoolActivity(provider.id, 8, isPoolOwner);
|
||||
|
||||
// --- Embedding verify ---
|
||||
const { verifyEmbedding, embVerifying, embResult, resetEmb } = useProviderVerify();
|
||||
useEffect(() => { resetEmb(); }, [embModel, resetEmb]);
|
||||
|
||||
// --- Save handler ---
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const nextDisplayName = displayName.trim();
|
||||
const nextEmbModel = embModel.trim();
|
||||
const nextEmbAPIBase = embApiBase.trim();
|
||||
const submittedAPIKey = showApiKey && apiKey && apiKey !== "***" ? apiKey : "";
|
||||
const data: ProviderInput = {
|
||||
name: provider.name,
|
||||
display_name: nextDisplayName || undefined,
|
||||
provider_type: provider.provider_type,
|
||||
enabled,
|
||||
};
|
||||
if (submittedAPIKey) {
|
||||
data.api_key = submittedAPIKey;
|
||||
}
|
||||
|
||||
const data: ProviderInput = { name: provider.name, display_name: nextDisplayName || undefined, provider_type: provider.provider_type, enabled };
|
||||
if (submittedAPIKey) data.api_key = submittedAPIKey;
|
||||
let nextSettings = { ...((provider.settings || {}) as Record<string, unknown>) };
|
||||
if (showEmbedding) {
|
||||
nextSettings = {
|
||||
...nextSettings,
|
||||
embedding: embEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
model: nextEmbModel || undefined,
|
||||
api_base: nextEmbAPIBase || undefined,
|
||||
}
|
||||
: { enabled: false },
|
||||
};
|
||||
nextSettings = { ...nextSettings, embedding: embEnabled ? { enabled: true, model: embModel.trim() || undefined, api_base: embApiBase.trim() || undefined } : { enabled: false } };
|
||||
}
|
||||
if (isOAuth) {
|
||||
nextSettings = buildProviderSettingsWithChatGPTOAuthRouting(
|
||||
nextSettings,
|
||||
poolRouting,
|
||||
);
|
||||
}
|
||||
nextSettings = buildProviderSettingsWithReasoningDefaults(
|
||||
nextSettings,
|
||||
showReasoningDefaults
|
||||
? {
|
||||
effort: reasoningExpert ? reasoningEffort : reasoningThinkingLevel,
|
||||
fallback: reasoningExpert ? reasoningFallback : "downgrade",
|
||||
}
|
||||
: null,
|
||||
);
|
||||
if (isOAuth) nextSettings = buildProviderSettingsWithChatGPTOAuthRouting(nextSettings, poolRouting);
|
||||
nextSettings = buildProviderSettingsWithReasoningDefaults(nextSettings, showReasoningDefaults ? { effort: reasoningExpert ? reasoningEffort : reasoningThinkingLevel, fallback: reasoningExpert ? reasoningFallback : "downgrade" } : null);
|
||||
data.settings = nextSettings;
|
||||
|
||||
await onUpdate(provider.id, data);
|
||||
setDisplayName(nextDisplayName);
|
||||
setEmbModel(nextEmbModel);
|
||||
setEmbApiBase(nextEmbAPIBase);
|
||||
if (submittedAPIKey) {
|
||||
setApiKey("***");
|
||||
}
|
||||
} catch {
|
||||
// toast shown by hook
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setDisplayName(nextDisplayName); setEmbModel(embModel.trim()); setEmbApiBase(embApiBase.trim());
|
||||
if (submittedAPIKey) setApiKey("***");
|
||||
} catch { /* toast shown by hook */ } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleCopyName = () => {
|
||||
navigator.clipboard.writeText(provider.name).catch(() => {});
|
||||
toast.success(tc("copy"));
|
||||
};
|
||||
|
||||
const handleVerifyEmbedding = () => {
|
||||
verifyEmbedding(provider.id, embModel.trim() || undefined, undefined);
|
||||
};
|
||||
|
||||
const displayNameDirty = displayName !== (provider.display_name || "");
|
||||
const enabledDirty = enabled !== provider.enabled;
|
||||
const apiKeyDirty =
|
||||
showApiKey &&
|
||||
comparableAPIKeyValue(apiKey, provider.api_key || "", showApiKey) !== "";
|
||||
const embeddingDirty =
|
||||
embEnabled !== (initEmb?.enabled ?? false)
|
||||
|| embModel !== (initEmb?.model ?? "")
|
||||
|| embApiBase !== (initEmb?.api_base ?? "");
|
||||
const routingDirty = isOAuth && routingSignature(poolRouting) !== routingSignature({
|
||||
strategy: initialRouting?.strategy ?? "primary_first",
|
||||
extra_provider_names: initialRouting?.extraProviderNames ?? [],
|
||||
});
|
||||
const reasoningDirty = draftReasoningSignature !== savedReasoningSignature;
|
||||
const isDirty = displayNameDirty || enabledDirty || apiKeyDirty || embeddingDirty || routingDirty || reasoningDirty;
|
||||
const isDirty = displayName !== (provider.display_name || "")
|
||||
|| enabled !== provider.enabled
|
||||
|| (showApiKey && comparableAPIKeyValue(apiKey, provider.api_key || "", showApiKey) !== "")
|
||||
|| embEnabled !== (initEmb?.enabled ?? false) || embModel !== (initEmb?.model ?? "") || embApiBase !== (initEmb?.api_base ?? "")
|
||||
|| (isOAuth && routingSignature(poolRouting) !== routingSignature({ strategy: initialRouting?.strategy ?? "primary_first", extra_provider_names: initialRouting?.extraProviderNames ?? [] }))
|
||||
|| draftReasoningSig !== savedReasoningSig;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Identity section */}
|
||||
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<h3 className="text-sm font-medium">{t("detail.identity")}</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">{t("form.displayName")}</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={isOAuth ? t("form.oauthDisplayNamePlaceholder") : t("form.displayNamePlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
{isOAuth ? (
|
||||
<p className="text-xs text-muted-foreground">{t("form.oauthDisplayNameHint")}</p>
|
||||
) : null}
|
||||
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder={isOAuth ? t("form.oauthDisplayNamePlaceholder") : t("form.displayNamePlaceholder")} className="text-base md:text-sm" />
|
||||
{isOAuth ? <p className="text-xs text-muted-foreground">{t("form.oauthDisplayNameHint")}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("detail.providerType")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{typeLabel}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2"><Badge variant="outline">{typeLabel}</Badge></div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{isOAuth ? t("form.oauthAlias") : t("form.name")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border bg-muted px-3 py-2 font-mono text-sm text-muted-foreground">
|
||||
{provider.name}
|
||||
</code>
|
||||
<Button type="button" variant="outline" size="icon" className="size-9 shrink-0" onClick={handleCopyName}>
|
||||
<code className="flex-1 rounded-md border bg-muted px-3 py-2 font-mono text-sm text-muted-foreground">{provider.name}</code>
|
||||
<Button type="button" variant="outline" size="icon" className="size-9 shrink-0" onClick={() => { navigator.clipboard.writeText(provider.name).catch(() => {}); toast.success(tc("copy")); }}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isOAuth ? (
|
||||
<ProviderOAuthAccountSection
|
||||
provider={provider}
|
||||
managedByProvider={managedByProvider}
|
||||
managedMemberCount={managedMemberCount}
|
||||
availability={currentOAuthAvailability}
|
||||
quota={quotaByName.get(provider.name)}
|
||||
quotaLoading={quotasLoading || quotasFetching}
|
||||
/>
|
||||
) : null}
|
||||
{isOAuth ? <ProviderOAuthAccountSection provider={provider} managedByProvider={managedByProvider} managedMemberCount={managedMemberCount} availability={currentOAuthAvailability} quota={quotaByName.get(provider.name)} quotaLoading={quotasLoading || quotasFetching} /> : null}
|
||||
|
||||
{showReasoningDefaults ? (
|
||||
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("detail.reasoningDefaultsTitle")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detail.reasoningDefaultsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("detail.reasoningPreset")}</Label>
|
||||
<Select
|
||||
value={reasoningThinkingLevel}
|
||||
onValueChange={(value) => {
|
||||
setReasoningThinkingLevel(value);
|
||||
setReasoningEffort(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["off", "low", "medium", "high"] as const).map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
<span>{t(`reasoning.${level}`)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`reasoning.${level}Desc`)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t("detail.reasoningExpertMode")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detail.reasoningExpertModeDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={reasoningExpert}
|
||||
onCheckedChange={(enabled) => {
|
||||
setReasoningExpert(enabled);
|
||||
if (!enabled) {
|
||||
const legacy = deriveLegacyThinkingLevel(reasoningEffort);
|
||||
setReasoningThinkingLevel(legacy);
|
||||
setReasoningFallback("downgrade");
|
||||
} else if (reasoningEffort === "off" && reasoningThinkingLevel !== "off") {
|
||||
setReasoningEffort(reasoningThinkingLevel);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
|
||||
{reasoningPreviewEntry ? (
|
||||
<>
|
||||
<p>
|
||||
{t("detail.reasoningPreviewDescription", {
|
||||
model: reasoningPreviewEntry.name || reasoningPreviewEntry.id,
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reasoningPreviewModel">{t("detail.reasoningPreviewLabel")}</Label>
|
||||
<Select value={reasoningPreviewEntry.id} onValueChange={setReasoningPreviewModel}>
|
||||
<SelectTrigger id="reasoningPreviewModel" className="w-full sm:w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reasoningCapableModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{reasoningPreviewCapability?.levels?.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{reasoningPreviewCapability.levels.map((level) => (
|
||||
<Badge key={level} variant="outline" className="text-[10px]">
|
||||
{t(`reasoning.${level}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{reasoningPreviewCapability?.default_effort ? (
|
||||
<p>
|
||||
{t("detail.reasoningPreviewDefault", {
|
||||
level: t(`reasoning.${reasoningPreviewCapability.default_effort}`),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p>{t("detail.reasoningPreviewEmpty")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reasoningExpert ? (
|
||||
<div className="mt-4 space-y-3 border-t pt-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("detail.reasoningRequestedEffort")}</Label>
|
||||
<Select value={reasoningEffort} onValueChange={setReasoningEffort}>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADVANCED_REASONING_LEVELS.map((effort) => (
|
||||
<SelectItem key={effort} value={effort}>
|
||||
<span>{t(`reasoning.${effort}`)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`reasoning.${effort}Desc`)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("detail.reasoningFallbackBehavior")}</Label>
|
||||
<Select
|
||||
value={reasoningFallback}
|
||||
onValueChange={(value) => setReasoningFallback(normalizeReasoningFallback(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REASONING_FALLBACKS.map((fallback) => (
|
||||
<SelectItem key={fallback} value={fallback}>
|
||||
<span>{t(`reasoning.${fallback}`)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`reasoning.${fallback}Desc`)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<ProviderReasoningSection
|
||||
reasoningThinkingLevel={reasoningThinkingLevel} setReasoningThinkingLevel={setReasoningThinkingLevel}
|
||||
reasoningEffort={reasoningEffort} setReasoningEffort={setReasoningEffort}
|
||||
reasoningFallback={reasoningFallback} setReasoningFallback={(v: string) => setReasoningFallback(v as typeof reasoningFallback)}
|
||||
reasoningExpert={reasoningExpert} setReasoningExpert={setReasoningExpert}
|
||||
setReasoningPreviewModel={setReasoningPreviewModel}
|
||||
reasoningCapableModels={reasoningCapableModels} reasoningPreviewEntry={reasoningPreviewEntry} reasoningPreviewCapability={reasoningPreviewCapability}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{canEditPoolRouting ? (
|
||||
<ChatGPTOAuthRoutingSection
|
||||
title={t("detail.codexPoolDefaultsTitle")}
|
||||
description={t("detail.codexPoolDefaultsDescription")}
|
||||
currentProvider={provider.name}
|
||||
providers={providers}
|
||||
value={poolRouting}
|
||||
onChange={setPoolRouting}
|
||||
showOverrideMode={false}
|
||||
canManageProviders
|
||||
quotaByName={quotaByName}
|
||||
quotaLoading={quotasLoading || quotasFetching}
|
||||
entries={poolEntries}
|
||||
/>
|
||||
<ChatGPTOAuthRoutingSection title={t("detail.codexPoolDefaultsTitle")} description={t("detail.codexPoolDefaultsDescription")} currentProvider={provider.name} providers={providers} value={poolRouting} onChange={setPoolRouting} showOverrideMode={false} canManageProviders quotaByName={quotaByName} quotaLoading={quotasLoading || quotasFetching} entries={poolEntries} />
|
||||
) : null}
|
||||
|
||||
{isPoolOwner ? (
|
||||
<ProviderPoolActivitySection
|
||||
provider={provider}
|
||||
providerCounts={poolActivity.provider_counts}
|
||||
recentRequests={poolActivity.recent_requests}
|
||||
topAgents={poolActivity.top_agents}
|
||||
statsSampleSize={poolActivity.stats_sample_size}
|
||||
fetching={poolActivityFetching}
|
||||
onRefresh={() => void refreshPoolActivity()}
|
||||
providerByName={providerByName}
|
||||
statusByName={statusByName}
|
||||
quotaByName={quotaByName}
|
||||
/>
|
||||
<ProviderPoolActivitySection provider={provider} providerCounts={poolActivity.provider_counts} recentRequests={poolActivity.recent_requests} topAgents={poolActivity.top_agents} statsSampleSize={poolActivity.stats_sample_size} fetching={poolActivityFetching} onRefresh={() => void refreshPoolActivity()} providerByName={providerByName} statusByName={statusByName} quotaByName={quotaByName} />
|
||||
) : null}
|
||||
|
||||
{showApiKey ? (
|
||||
@@ -748,127 +263,28 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
|
||||
<h3 className="text-sm font-medium">{t("detail.apiKeySection")}</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">{t("form.apiKey")}</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t("form.apiKeyEditPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<Input id="apiKey" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={t("form.apiKeyEditPlaceholder")} className="text-base md:text-sm" />
|
||||
<p className="text-xs text-muted-foreground">{t("form.apiKeySetHint")}</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showEmbedding ? (
|
||||
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<h3 className="text-sm font-medium">{t("detail.embeddingSection")}</h3>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="embEnabled" className="text-sm font-medium">
|
||||
{t("embedding.enable")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.enableDesc")}</p>
|
||||
</div>
|
||||
<Switch id="embEnabled" checked={embEnabled} onCheckedChange={setEmbEnabled} />
|
||||
</div>
|
||||
|
||||
{embEnabled ? (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embModel">{t("embedding.model")}</Label>
|
||||
<Input
|
||||
id="embModel"
|
||||
value={embModel}
|
||||
onChange={(e) => setEmbModel(e.target.value)}
|
||||
placeholder="text-embedding-3-small"
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("embedding.dimensions")}</Label>
|
||||
<p className="text-sm text-muted-foreground">1536</p>
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.dimensionsHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embApiBase">{t("embedding.apiBase")}</Label>
|
||||
<Input
|
||||
id="embApiBase"
|
||||
value={embApiBase}
|
||||
onChange={(e) => setEmbApiBase(e.target.value)}
|
||||
placeholder={t("embedding.apiBasePlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("embedding.apiBaseHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={embVerifying}
|
||||
onClick={handleVerifyEmbedding}
|
||||
>
|
||||
{embVerifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{t("embedding.verify")}
|
||||
</Button>
|
||||
{embResult ? (
|
||||
<span
|
||||
className={`flex items-center gap-1 text-xs ${
|
||||
embResult.valid
|
||||
? embResult.dimension_mismatch
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{embResult.valid ? (
|
||||
<>
|
||||
{embResult.dimension_mismatch ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{embResult.dimension_mismatch
|
||||
? t("embedding.dimensionsMismatch", { count: embResult.dimensions })
|
||||
: `${embResult.dimensions} dimensions`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{embResult.error || t("embedding.verifyFailed")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<ProviderEmbeddingSection embEnabled={embEnabled} setEmbEnabled={setEmbEnabled} embModel={embModel} setEmbModel={setEmbModel} embApiBase={embApiBase} setEmbApiBase={setEmbApiBase} onVerify={() => verifyEmbedding(provider.id, embModel.trim() || undefined, undefined)} verifying={embVerifying} verifyResult={embResult} />
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<h3 className="text-sm font-medium">{t("detail.statusSection")}</h3>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled" className="text-sm font-medium">
|
||||
{t("form.enabled")}
|
||||
</Label>
|
||||
<Label htmlFor="enabled" className="text-sm font-medium">{t("form.enabled")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.enabledDesc")}</p>
|
||||
</div>
|
||||
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StickySaveBar
|
||||
onSave={handleSave}
|
||||
saving={saving}
|
||||
disabled={!isDirty}
|
||||
/>
|
||||
<StickySaveBar onSave={handleSave} saving={saving} disabled={!isDirty} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { deriveLegacyThinkingLevel, normalizeReasoningFallback } from "@/types/provider";
|
||||
import type { ReasoningCapability } from "@/types/provider";
|
||||
import { ADVANCED_REASONING_LEVELS, REASONING_FALLBACKS } from "./provider-overview-helpers";
|
||||
|
||||
interface ReasoningModelEntry {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: ReasoningCapability | null;
|
||||
}
|
||||
|
||||
interface ProviderReasoningSectionProps {
|
||||
reasoningThinkingLevel: string;
|
||||
setReasoningThinkingLevel: (v: string) => void;
|
||||
reasoningEffort: string;
|
||||
setReasoningEffort: (v: string) => void;
|
||||
reasoningFallback: string;
|
||||
setReasoningFallback: (v: string) => void;
|
||||
reasoningExpert: boolean;
|
||||
setReasoningExpert: (v: boolean) => void;
|
||||
setReasoningPreviewModel: (v: string) => void;
|
||||
reasoningCapableModels: ReasoningModelEntry[];
|
||||
reasoningPreviewEntry: ReasoningModelEntry | null;
|
||||
reasoningPreviewCapability: ReasoningCapability | null;
|
||||
}
|
||||
|
||||
export function ProviderReasoningSection({
|
||||
reasoningThinkingLevel,
|
||||
setReasoningThinkingLevel,
|
||||
reasoningEffort,
|
||||
setReasoningEffort,
|
||||
reasoningFallback,
|
||||
setReasoningFallback,
|
||||
reasoningExpert,
|
||||
setReasoningExpert,
|
||||
setReasoningPreviewModel,
|
||||
reasoningCapableModels,
|
||||
reasoningPreviewEntry,
|
||||
reasoningPreviewCapability,
|
||||
}: ProviderReasoningSectionProps) {
|
||||
const { t } = useTranslation("providers");
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("detail.reasoningDefaultsTitle")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detail.reasoningDefaultsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("detail.reasoningPreset")}</Label>
|
||||
<Select
|
||||
value={reasoningThinkingLevel}
|
||||
onValueChange={(value) => {
|
||||
setReasoningThinkingLevel(value);
|
||||
setReasoningEffort(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["off", "low", "medium", "high"] as const).map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
<span>{t(`reasoning.${level}`)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`reasoning.${level}Desc`)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t("detail.reasoningExpertMode")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detail.reasoningExpertModeDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={reasoningExpert}
|
||||
onCheckedChange={(enabled) => {
|
||||
setReasoningExpert(enabled);
|
||||
if (!enabled) {
|
||||
const legacy = deriveLegacyThinkingLevel(reasoningEffort);
|
||||
setReasoningThinkingLevel(legacy);
|
||||
setReasoningFallback("downgrade");
|
||||
} else if (reasoningEffort === "off" && reasoningThinkingLevel !== "off") {
|
||||
setReasoningEffort(reasoningThinkingLevel);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
|
||||
{reasoningPreviewEntry ? (
|
||||
<>
|
||||
<p>
|
||||
{t("detail.reasoningPreviewDescription", {
|
||||
model: reasoningPreviewEntry.name || reasoningPreviewEntry.id,
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reasoningPreviewModel">{t("detail.reasoningPreviewLabel")}</Label>
|
||||
<Select value={reasoningPreviewEntry.id} onValueChange={setReasoningPreviewModel}>
|
||||
<SelectTrigger id="reasoningPreviewModel" className="w-full sm:w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reasoningCapableModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{reasoningPreviewCapability?.levels?.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{reasoningPreviewCapability.levels.map((level) => (
|
||||
<Badge key={level} variant="outline" className="text-[10px]">
|
||||
{t(`reasoning.${level}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{reasoningPreviewCapability?.default_effort ? (
|
||||
<p>
|
||||
{t("detail.reasoningPreviewDefault", {
|
||||
level: t(`reasoning.${reasoningPreviewCapability.default_effort}`),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p>{t("detail.reasoningPreviewEmpty")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reasoningExpert ? (
|
||||
<div className="mt-4 space-y-3 border-t pt-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("detail.reasoningRequestedEffort")}</Label>
|
||||
<Select value={reasoningEffort} onValueChange={setReasoningEffort}>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADVANCED_REASONING_LEVELS.map((effort) => (
|
||||
<SelectItem key={effort} value={effort}>
|
||||
<span>{t(`reasoning.${effort}`)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`reasoning.${effort}Desc`)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("detail.reasoningFallbackBehavior")}</Label>
|
||||
<Select
|
||||
value={reasoningFallback}
|
||||
onValueChange={(value) => setReasoningFallback(normalizeReasoningFallback(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REASONING_FALLBACKS.map((fallback) => (
|
||||
<SelectItem key={fallback} value={fallback}>
|
||||
<span>{t(`reasoning.${fallback}`)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`reasoning.${fallback}Desc`)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef, useLayoutEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, Trash2, RotateCcw, Info, Eye, Pencil, Check, X } from "lucide-react";
|
||||
import { ArrowLeft, Trash2, RotateCcw, Eye, Pencil, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MessageBubble } from "@/components/chat/message-bubble";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
@@ -14,6 +13,7 @@ import { formatDate, formatTokens } from "@/lib/format";
|
||||
import type { SessionInfo, SessionPreview, Message } from "@/types/session";
|
||||
import type { ChatMessage, AgentEventPayload, ToolStreamEntry } from "@/types/chat";
|
||||
import { messageToTimestamp } from "@/lib/message-utils";
|
||||
import { SystemMessageBlock, SummaryBlock } from "./session-message-blocks";
|
||||
|
||||
/** Check if a message is an internal system message (subagent results, cron, etc.) */
|
||||
function isSystemMessage(msg: ChatMessage): boolean {
|
||||
@@ -273,73 +273,3 @@ export function SessionDetailPage({
|
||||
);
|
||||
}
|
||||
|
||||
function SystemMessageBlock({ content }: { content: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Extract the first line as title, rest as body
|
||||
const lines = content.split("\n");
|
||||
const title = (lines[0] ?? "").replace(/^\[System Message\]\s*/, "").trim();
|
||||
const body = lines.slice(1).join("\n").trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl items-start gap-2 rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 px-4 py-2">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{title || t("detail.systemMessage")}</span>
|
||||
{body && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="ml-1 cursor-pointer text-primary hover:underline"
|
||||
>
|
||||
{expanded ? t("detail.hide") : t("detail.showDetails")}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2">
|
||||
<MarkdownRenderer content={body} className="text-xs" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUMMARY_MAX_HEIGHT = 72; // ~3 lines of text
|
||||
|
||||
function SummaryBlock({ text }: { text: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [needsTruncation, setNeedsTruncation] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (contentRef.current) {
|
||||
setNeedsTruncation(contentRef.current.scrollHeight > SUMMARY_MAX_HEIGHT);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="border-b bg-muted/50 px-6 py-3 text-sm">
|
||||
<span className="font-medium">{t("detail.summary")}: </span>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="mt-1 overflow-hidden transition-[max-height] duration-200"
|
||||
style={{ maxHeight: expanded ? contentRef.current?.scrollHeight : SUMMARY_MAX_HEIGHT }}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 cursor-pointer text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{expanded ? t("detail.showLess") : t("detail.showMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Sub-components for session detail view:
|
||||
* - SystemMessageBlock: collapsible system/internal message display
|
||||
* - SummaryBlock: collapsible session summary with truncation
|
||||
*/
|
||||
import { useState, useRef, useLayoutEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
|
||||
export function SystemMessageBlock({ content }: { content: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Extract the first line as title, rest as body
|
||||
const lines = content.split("\n");
|
||||
const title = (lines[0] ?? "").replace(/^\[System Message\]\s*/, "").trim();
|
||||
const body = lines.slice(1).join("\n").trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl items-start gap-2 rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 px-4 py-2">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{title || t("detail.systemMessage")}</span>
|
||||
{body && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="ml-1 cursor-pointer text-primary hover:underline"
|
||||
>
|
||||
{expanded ? t("detail.hide") : t("detail.showDetails")}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2">
|
||||
<MarkdownRenderer content={body} className="text-xs" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUMMARY_MAX_HEIGHT = 72; // ~3 lines of text
|
||||
|
||||
export function SummaryBlock({ text }: { text: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [needsTruncation, setNeedsTruncation] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (contentRef.current) {
|
||||
setNeedsTruncation(contentRef.current.scrollHeight > SUMMARY_MAX_HEIGHT);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="border-b bg-muted/50 px-6 py-3 text-sm">
|
||||
<span className="font-medium">{t("detail.summary")}: </span>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="mt-1 overflow-hidden transition-[max-height] duration-200"
|
||||
style={{ maxHeight: expanded ? contentRef.current?.scrollHeight : SUMMARY_MAX_HEIGHT }}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 cursor-pointer text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{expanded ? t("detail.showLess") : t("detail.showMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SkillTenantOverride } from "./skill-tenant-override";
|
||||
import type { SkillInfo } from "./hooks/use-skills";
|
||||
|
||||
const visibilityColor: Record<string, string> = {
|
||||
public: "default",
|
||||
internal: "secondary",
|
||||
private: "outline",
|
||||
};
|
||||
|
||||
interface SkillTableRowProps {
|
||||
skill: SkillInfo;
|
||||
tab: "core" | "custom";
|
||||
hasTenantScope: boolean;
|
||||
toggling: string | null;
|
||||
onView: (name: string) => void;
|
||||
onEdit: (skill: SkillInfo) => void;
|
||||
onDelete: (skill: SkillInfo) => void;
|
||||
onToggle: (skill: SkillInfo, enabled: boolean) => void;
|
||||
onCycleVisibility: (skill: SkillInfo) => void;
|
||||
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Single row in the skills table with inline status, visibility, and action controls. */
|
||||
export function SkillTableRow({
|
||||
skill, tab, hasTenantScope, toggling,
|
||||
onView, onEdit, onDelete, onToggle, onCycleVisibility,
|
||||
onSetTenantConfig, onDeleteTenantConfig,
|
||||
}: SkillTableRowProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const isArchived = skill.status === "archived";
|
||||
const isDisabled = skill.enabled === false;
|
||||
const hasMissing = (skill.missing_deps?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<tr className={cn("border-b last:border-0 hover:bg-muted/30", (isArchived || isDisabled) && "opacity-60")}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-left hover:underline cursor-pointer"
|
||||
onClick={() => onView(skill.slug ?? skill.name)}
|
||||
>
|
||||
{skill.name}
|
||||
</button>
|
||||
{skill.is_system && (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600 text-[10px]">
|
||||
{t("system")}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.version && <span className="text-xs text-muted-foreground">v{skill.version}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-4 py-3 text-muted-foreground">
|
||||
{skill.description || t("noDescription")}
|
||||
</td>
|
||||
{tab === "custom" && (
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{skill.author || "—"}</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] w-fit",
|
||||
isArchived
|
||||
? "border-amber-500 text-amber-600 dark:border-amber-600 dark:text-amber-400"
|
||||
: "border-emerald-500 text-emerald-600 dark:border-emerald-600 dark:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
{isArchived ? t("deps.statusArchived") : t("deps.statusActive")}
|
||||
</Badge>
|
||||
{hasMissing && (() => {
|
||||
const deps = skill.missing_deps!.map((d) => d.replace(/^(pip|npm):/, ""));
|
||||
const shown = deps.slice(0, 3);
|
||||
const rest = deps.length - shown.length;
|
||||
return (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 leading-tight">
|
||||
{shown.join(", ")}{rest > 0 && `, +${rest}`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
{tab === "custom" && (
|
||||
<td className="px-4 py-3">
|
||||
{skill.visibility && (
|
||||
skill.id ? (
|
||||
<button type="button" onClick={() => onCycleVisibility(skill)} title={t("visibility.clickToCycle")}>
|
||||
<Badge
|
||||
variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{skill.visibility}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}>
|
||||
{skill.visibility}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{skill.id && (
|
||||
<>
|
||||
{hasTenantScope ? (
|
||||
<SkillTenantOverride
|
||||
skill={skill}
|
||||
toggling={toggling === skill.id}
|
||||
onSetTenantConfig={onSetTenantConfig}
|
||||
onDeleteTenantConfig={onDeleteTenantConfig}
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={skill.enabled !== false}
|
||||
disabled={toggling === skill.id}
|
||||
onCheckedChange={(checked) => onToggle(skill, checked)}
|
||||
title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")}
|
||||
/>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(skill)} className="gap-1">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!skill.is_system && (
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
onClick={() => onDelete(skill)}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { SkillInfo } from "./hooks/use-skills";
|
||||
|
||||
interface SkillTenantOverrideProps {
|
||||
skill: SkillInfo;
|
||||
toggling: boolean;
|
||||
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Per-tenant enable/disable override control shown when a tenant scope is active. */
|
||||
export function SkillTenantOverride({
|
||||
skill,
|
||||
toggling,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: SkillTenantOverrideProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const hasOverride = skill.tenant_enabled !== null && skill.tenant_enabled !== undefined;
|
||||
const checked = hasOverride ? (skill.tenant_enabled ?? false) : (skill.enabled !== false);
|
||||
const label = hasOverride
|
||||
? skill.tenant_enabled ? t("tenant.enabled") : t("tenant.disabled")
|
||||
: t("tenant.default");
|
||||
const badgeVariant = hasOverride
|
||||
? skill.tenant_enabled ? "default" : "secondary"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={badgeVariant as "default" | "secondary" | "outline"}
|
||||
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("tenant.overrideHint")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={toggling}
|
||||
onCheckedChange={(val) => onSetTenantConfig(skill.id!, val)}
|
||||
aria-label={t("tenant.overrideHint")}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={toggling}
|
||||
onClick={() => onDeleteTenantConfig(skill.id!)}
|
||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("tenant.resetDefault")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap, Pencil, RefreshCw, Upload, Trash2, ScanSearch, X } from "lucide-react";
|
||||
import { Zap, RefreshCw, Upload, ScanSearch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
@@ -17,6 +14,7 @@ import { SkillDetailDialog } from "./skill-detail-dialog";
|
||||
import { SkillUploadDialog } from "./skill-upload-dialog";
|
||||
import { SkillEditDialog } from "./skill-edit-dialog";
|
||||
import { MissingDepsPanel } from "./missing-deps-panel";
|
||||
import { SkillTableRow } from "./skill-table-row";
|
||||
import { useRuntimes } from "./hooks/use-runtimes";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
@@ -25,12 +23,6 @@ import { useTenants } from "@/hooks/use-tenants";
|
||||
|
||||
const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001";
|
||||
|
||||
const visibilityColor: Record<string, string> = {
|
||||
public: "default",
|
||||
internal: "secondary",
|
||||
private: "outline",
|
||||
};
|
||||
|
||||
type Tab = "core" | "custom";
|
||||
|
||||
export function SkillsPage() {
|
||||
@@ -58,15 +50,12 @@ export function SkillsPage() {
|
||||
const coreSkills = skills.filter((s: SkillInfo) => s.is_system);
|
||||
const customSkills = skills.filter((s: SkillInfo) => !s.is_system);
|
||||
const tabSkills = tab === "core" ? coreSkills : customSkills;
|
||||
|
||||
const allMissing = [...new Set(tabSkills.flatMap((s: SkillInfo) => s.missing_deps ?? []))];
|
||||
|
||||
const filtered = tabSkills.filter(
|
||||
(s: SkillInfo) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
|
||||
|
||||
useEffect(() => { resetPage(); }, [search, tab, resetPage]);
|
||||
@@ -76,47 +65,39 @@ export function SkillsPage() {
|
||||
if (detail) setSelectedSkill(detail);
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
return uploadSkill(file);
|
||||
};
|
||||
|
||||
const handleCycleVisibility = async (skill: SkillInfo) => {
|
||||
if (!skill.id) return;
|
||||
const order = ["private", "internal", "public"] as const;
|
||||
const idx = order.indexOf(skill.visibility as typeof order[number]);
|
||||
const next = order[(idx + 1) % order.length];
|
||||
await updateSkill(skill.id, { visibility: next });
|
||||
await updateSkill(skill.id, { visibility: order[(idx + 1) % order.length] });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteSkill(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
refresh();
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
try { await deleteSkill(deleteTarget.id); setDeleteTarget(null); refresh(); }
|
||||
finally { setDeleteLoading(false); }
|
||||
};
|
||||
|
||||
const handleRescanDeps = async () => {
|
||||
setRescanning(true);
|
||||
try {
|
||||
await rescanDeps();
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
try { await rescanDeps(); } finally { setRescanning(false); }
|
||||
};
|
||||
|
||||
const handleToggle = async (skill: SkillInfo, enabled: boolean) => {
|
||||
if (!skill.id) return;
|
||||
setToggling(skill.id);
|
||||
try {
|
||||
await toggleSkill(skill.id, enabled);
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
try { await toggleSkill(skill.id, enabled); } finally { setToggling(null); }
|
||||
};
|
||||
|
||||
const handleSetTenantConfig = async (id: string, enabled: boolean) => {
|
||||
setToggling(id);
|
||||
try { await setTenantConfig(id, enabled); } finally { setToggling(null); }
|
||||
};
|
||||
|
||||
const handleDeleteTenantConfig = async (id: string) => {
|
||||
setToggling(id);
|
||||
try { await deleteTenantConfig(id); } finally { setToggling(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -141,47 +122,27 @@ export function SkillsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-medium border-b-2 -mb-px",
|
||||
tab === "core"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab("core")}
|
||||
>
|
||||
{t("tabs.core")} ({coreSkills.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-medium border-b-2 -mb-px",
|
||||
tab === "custom"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab("custom")}
|
||||
>
|
||||
{t("tabs.custom")} ({customSkills.length})
|
||||
</button>
|
||||
{(["core", "custom"] as Tab[]).map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-medium border-b-2 -mb-px",
|
||||
tab === tabKey
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab(tabKey)}
|
||||
>
|
||||
{t(`tabs.${tabKey}`)} ({tabKey === "core" ? coreSkills.length : customSkills.length})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<MissingDepsPanel
|
||||
missing={allMissing}
|
||||
onInstallItem={installSingleDep}
|
||||
runtimes={tab === "core" ? runtimes : undefined}
|
||||
/>
|
||||
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<MissingDepsPanel missing={allMissing} onInstallItem={installSingleDep} runtimes={tab === "core" ? runtimes : undefined} />
|
||||
<SearchInput value={search} onChange={setSearch} placeholder={t("searchPlaceholder")} className="max-w-sm" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -207,138 +168,22 @@ export function SkillsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((skill: SkillInfo) => {
|
||||
const isArchived = skill.status === "archived";
|
||||
const isDisabled = skill.enabled === false;
|
||||
const hasMissing = (skill.missing_deps?.length ?? 0) > 0;
|
||||
return (
|
||||
<tr key={skill.name} className={cn("border-b last:border-0 hover:bg-muted/30", (isArchived || isDisabled) && "opacity-60")}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-left hover:underline cursor-pointer"
|
||||
onClick={() => handleViewSkill(skill.slug ?? skill.name)}
|
||||
>
|
||||
{skill.name}
|
||||
</button>
|
||||
{skill.is_system && (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600 text-[10px]">
|
||||
{t("system")}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.version ? (
|
||||
<span className="text-xs text-muted-foreground">v{skill.version}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-4 py-3 text-muted-foreground">
|
||||
{skill.description || t("noDescription")}
|
||||
</td>
|
||||
{tab === "custom" && (
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{skill.author || "—"}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] w-fit",
|
||||
isArchived
|
||||
? "border-amber-500 text-amber-600 dark:border-amber-600 dark:text-amber-400"
|
||||
: "border-emerald-500 text-emerald-600 dark:border-emerald-600 dark:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
{isArchived ? t("deps.statusArchived") : t("deps.statusActive")}
|
||||
</Badge>
|
||||
{hasMissing && (() => {
|
||||
const deps = skill.missing_deps!.map((d) => d.replace(/^(pip|npm):/, ""));
|
||||
const shown = deps.slice(0, 3);
|
||||
const rest = deps.length - shown.length;
|
||||
return (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 leading-tight">
|
||||
{shown.join(", ")}{rest > 0 && `, +${rest}`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
{tab === "custom" && <td className="px-4 py-3">
|
||||
{skill.visibility && (
|
||||
skill.id ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCycleVisibility(skill)}
|
||||
title={t("visibility.clickToCycle")}
|
||||
>
|
||||
<Badge
|
||||
variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{skill.visibility}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}>
|
||||
{skill.visibility}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</td>}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{skill.id && (
|
||||
<>
|
||||
{hasTenantScope ? (
|
||||
<SkillTenantOverride
|
||||
skill={skill}
|
||||
toggling={toggling === skill.id}
|
||||
onSetTenantConfig={async (id, enabled) => {
|
||||
setToggling(id);
|
||||
try { await setTenantConfig(id, enabled); } finally { setToggling(null); }
|
||||
}}
|
||||
onDeleteTenantConfig={async (id) => {
|
||||
setToggling(id);
|
||||
try { await deleteTenantConfig(id); } finally { setToggling(null); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={skill.enabled !== false}
|
||||
disabled={toggling === skill.id}
|
||||
onCheckedChange={(checked) => handleToggle(skill, checked)}
|
||||
title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditTarget(skill)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!skill.is_system && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(skill)}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{pageItems.map((skill: SkillInfo) => (
|
||||
<SkillTableRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
tab={tab}
|
||||
hasTenantScope={hasTenantScope}
|
||||
toggling={toggling}
|
||||
onView={handleViewSkill}
|
||||
onEdit={setEditTarget}
|
||||
onDelete={setDeleteTarget}
|
||||
onToggle={handleToggle}
|
||||
onCycleVisibility={handleCycleVisibility}
|
||||
onSetTenantConfig={handleSetTenantConfig}
|
||||
onDeleteTenantConfig={handleDeleteTenantConfig}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
@@ -367,18 +212,11 @@ export function SkillsPage() {
|
||||
<SkillEditDialog
|
||||
skill={editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
onSave={async (id, updates) => {
|
||||
await updateSkill(id, updates);
|
||||
setEditTarget(null);
|
||||
}}
|
||||
onSave={async (id, updates) => { await updateSkill(id, updates); setEditTarget(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SkillUploadDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
<SkillUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onUpload={(f) => uploadSkill(f)} />
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={!!deleteTarget}
|
||||
@@ -393,72 +231,3 @@ export function SkillsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillTenantOverride({
|
||||
skill,
|
||||
toggling,
|
||||
onSetTenantConfig,
|
||||
onDeleteTenantConfig,
|
||||
}: {
|
||||
skill: SkillInfo;
|
||||
toggling: boolean;
|
||||
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation("skills");
|
||||
const hasOverride = skill.tenant_enabled !== null && skill.tenant_enabled !== undefined;
|
||||
const checked = hasOverride ? (skill.tenant_enabled ?? false) : (skill.enabled !== false);
|
||||
const label = hasOverride
|
||||
? skill.tenant_enabled ? t("tenant.enabled") : t("tenant.disabled")
|
||||
: t("tenant.default");
|
||||
const badgeVariant = hasOverride
|
||||
? skill.tenant_enabled ? "default" : "secondary"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={badgeVariant as "default" | "secondary" | "outline"}
|
||||
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("tenant.overrideHint")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={toggling}
|
||||
onCheckedChange={(val) => onSetTenantConfig(skill.id!, val)}
|
||||
aria-label={t("tenant.overrideHint")}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={toggling}
|
||||
onClick={() => onDeleteTenantConfig(skill.id!)}
|
||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">{t("tenant.resetDefault")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MultiUserPicker } from "@/components/shared/multi-user-picker";
|
||||
import { MultiSelect } from "./team-multi-select";
|
||||
|
||||
interface TeamAccessControlSectionProps {
|
||||
allowUserIds: string[];
|
||||
setAllowUserIds: (v: string[]) => void;
|
||||
denyUserIds: string[];
|
||||
setDenyUserIds: (v: string[]) => void;
|
||||
allowChannels: string[];
|
||||
setAllowChannels: (v: string[]) => void;
|
||||
denyChannels: string[];
|
||||
setDenyChannels: (v: string[]) => void;
|
||||
channelOptions: { value: string; label?: string }[];
|
||||
}
|
||||
|
||||
/** User and channel allow/deny lists for team access control settings. */
|
||||
export function TeamAccessControlSection({
|
||||
allowUserIds, setAllowUserIds,
|
||||
denyUserIds, setDenyUserIds,
|
||||
allowChannels, setAllowChannels,
|
||||
denyChannels, setDenyChannels,
|
||||
channelOptions,
|
||||
}: TeamAccessControlSectionProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* User Access Control */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.allowedUsers")}</label>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.allowedUsersHint")}</p>
|
||||
<MultiUserPicker value={allowUserIds} onChange={setAllowUserIds} placeholder={t("settings.searchUsers")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.deniedUsers")}</label>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.deniedUsersHint")}</p>
|
||||
<MultiUserPicker value={denyUserIds} onChange={setDenyUserIds} placeholder={t("settings.searchUsers")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Restrictions */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.channelRestrictions")}</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.allowedChannels")}</label>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.allowedChannelsHint")}</p>
|
||||
<MultiSelect options={channelOptions} selected={allowChannels} onChange={setAllowChannels} placeholder={t("settings.selectChannel")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.deniedChannels")}</label>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.deniedChannelsHint")}</p>
|
||||
<MultiSelect options={channelOptions} selected={denyChannels} onChange={setDenyChannels} placeholder={t("settings.selectChannel")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
|
||||
/** Generic multi-select with static options (used for channel type selection). */
|
||||
export function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
options: { value: string; label?: string }[];
|
||||
selected: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Combobox
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
if (val && !selected.includes(val)) {
|
||||
onChange([...selected, val]);
|
||||
}
|
||||
}}
|
||||
options={options.filter((o) => !selected.includes(o.value))}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="gap-1 pr-1">
|
||||
{options.find((o) => o.value === id)?.label ?? id}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter((s) => s !== id))}
|
||||
className="ml-0.5 cursor-pointer rounded-full p-0.5 hover:bg-muted"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Bell, Zap, Bot } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TeamNotificationsSectionProps {
|
||||
notifyDispatched: boolean;
|
||||
setNotifyDispatched: (v: boolean) => void;
|
||||
notifyProgress: boolean;
|
||||
setNotifyProgress: (v: boolean) => void;
|
||||
notifyFailed: boolean;
|
||||
setNotifyFailed: (v: boolean) => void;
|
||||
notifyCompleted: boolean;
|
||||
setNotifyCompleted: (v: boolean) => void;
|
||||
notifyCommented: boolean;
|
||||
setNotifyCommented: (v: boolean) => void;
|
||||
notifyNewTask: boolean;
|
||||
setNotifyNewTask: (v: boolean) => void;
|
||||
notifySlowTool: boolean;
|
||||
setNotifySlowTool: (v: boolean) => void;
|
||||
notifyMode: "direct" | "leader";
|
||||
setNotifyMode: (v: "direct" | "leader") => void;
|
||||
}
|
||||
|
||||
/** Notification toggles and delivery mode selector for team settings. */
|
||||
export function TeamNotificationsSection({
|
||||
notifyDispatched, setNotifyDispatched,
|
||||
notifyProgress, setNotifyProgress,
|
||||
notifyFailed, setNotifyFailed,
|
||||
notifyCompleted, setNotifyCompleted,
|
||||
notifyCommented, setNotifyCommented,
|
||||
notifyNewTask, setNotifyNewTask,
|
||||
notifySlowTool, setNotifySlowTool,
|
||||
notifyMode, setNotifyMode,
|
||||
}: TeamNotificationsSectionProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
|
||||
const modeOptions = [
|
||||
{ value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" },
|
||||
{ value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.notifications")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-blue-500/5 to-orange-500/5 p-4 space-y-3">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-blue-500/10 p-2.5 text-blue-600 dark:text-blue-400">
|
||||
<Bell className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{[
|
||||
{ label: t("settings.notifyDispatched"), hint: t("settings.notifyDispatchedHint"), checked: notifyDispatched, onChange: setNotifyDispatched },
|
||||
{ label: t("settings.notifyProgress"), hint: t("settings.notifyProgressHint"), checked: notifyProgress, onChange: setNotifyProgress },
|
||||
{ label: t("settings.notifyFailed"), hint: t("settings.notifyFailedHint"), checked: notifyFailed, onChange: setNotifyFailed },
|
||||
{ label: t("settings.notifyCompleted"), hint: undefined, checked: notifyCompleted, onChange: setNotifyCompleted },
|
||||
{ label: t("settings.notifyCommented"), hint: undefined, checked: notifyCommented, onChange: setNotifyCommented },
|
||||
{ label: t("settings.notifyNewTask"), hint: undefined, checked: notifyNewTask, onChange: setNotifyNewTask },
|
||||
{ label: t("settings.notifySlowTool"), hint: t("settings.notifySlowToolHint"), checked: notifySlowTool, onChange: setNotifySlowTool },
|
||||
].map(({ label, hint, checked, onChange }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<span className="text-sm font-semibold">{t("settings.notifyMode")}</span>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{modeOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setNotifyMode(opt.value)}
|
||||
className={
|
||||
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
|
||||
(notifyMode === opt.value
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t(`settings.${opt.descKey}`)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notifyMode === "leader" && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
⚠️ {t("settings.notifyModeLeaderWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ShieldAlert, Clock, FolderLock, FolderSync } from "lucide-react";
|
||||
|
||||
interface TeamOrchestrationSectionProps {
|
||||
workspaceScope: string;
|
||||
setWorkspaceScope: (v: string) => void;
|
||||
memberRequestsEnabled: boolean;
|
||||
setMemberRequestsEnabled: (v: boolean) => void;
|
||||
memberRequestsAutoDispatch: boolean;
|
||||
setMemberRequestsAutoDispatch: (v: boolean) => void;
|
||||
blockerEscalationEnabled: boolean;
|
||||
setBlockerEscalationEnabled: (v: boolean) => void;
|
||||
followupInterval: number;
|
||||
setFollowupInterval: (v: number) => void;
|
||||
followupMaxReminders: number;
|
||||
setFollowupMaxReminders: (v: number) => void;
|
||||
}
|
||||
|
||||
const WORKSPACE_OPTIONS = [
|
||||
{ value: "isolated", Icon: FolderLock, labelKey: "workspaceScopeIsolated", descKey: "workspaceScopeIsolatedDesc" },
|
||||
{ value: "shared", Icon: FolderSync, labelKey: "workspaceScopeShared", descKey: "workspaceScopeSharedDesc" },
|
||||
] as const;
|
||||
|
||||
/** Workspace scope, member requests, blocker escalation, and follow-up reminder settings. */
|
||||
export function TeamOrchestrationSection({
|
||||
workspaceScope, setWorkspaceScope,
|
||||
memberRequestsEnabled, setMemberRequestsEnabled,
|
||||
memberRequestsAutoDispatch, setMemberRequestsAutoDispatch,
|
||||
blockerEscalationEnabled, setBlockerEscalationEnabled,
|
||||
followupInterval, setFollowupInterval,
|
||||
followupMaxReminders, setFollowupMaxReminders,
|
||||
}: TeamOrchestrationSectionProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Workspace Scope */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.workspace")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-emerald-500/5 to-teal-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2.5 text-emerald-600 dark:text-emerald-400">
|
||||
<FolderSync className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-semibold">{t("settings.workspaceScope")}</span>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t("settings.workspaceScopeHint")}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{WORKSPACE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setWorkspaceScope(opt.value)}
|
||||
className={
|
||||
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
|
||||
(workspaceScope === opt.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{t(`settings.${opt.descKey}`)}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member Requests */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.memberRequests")}</h3>
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.memberRequestsEnabled")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.memberRequestsEnabledDesc")}</p>
|
||||
</div>
|
||||
<Switch checked={memberRequestsEnabled} onCheckedChange={setMemberRequestsEnabled} />
|
||||
</div>
|
||||
{memberRequestsEnabled && (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.memberRequestsAutoDispatch")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.memberRequestsAutoDispatchDesc")}</p>
|
||||
</div>
|
||||
<Switch checked={memberRequestsAutoDispatch} onCheckedChange={setMemberRequestsAutoDispatch} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocker Escalation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.blockerEscalation")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-orange-500/5 to-red-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-orange-500/10 p-2.5 text-orange-600 dark:text-orange-400">
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-semibold">{t("settings.blockerEscalationEnabled")}</span>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t("settings.blockerEscalationHint")}</p>
|
||||
</div>
|
||||
<Switch checked={blockerEscalationEnabled} onCheckedChange={setBlockerEscalationEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Follow-up Reminders */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.followupReminders")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-amber-500/5 to-yellow-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-amber-500/10 p-2.5 text-amber-600 dark:text-amber-400">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.followupInterval")}</label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t("settings.followupIntervalHint")}</p>
|
||||
<input
|
||||
type="number" min={1} max={1440}
|
||||
value={followupInterval}
|
||||
onChange={(e) => setFollowupInterval(Math.max(1, parseInt(e.target.value) || 30))}
|
||||
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.followupMaxReminders")}</label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t("settings.followupMaxRemindersHint")}</p>
|
||||
<input
|
||||
type="number" min={0} max={100}
|
||||
value={followupMaxReminders}
|
||||
onChange={(e) => setFollowupMaxReminders(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { X, Save, Bell, ShieldAlert, Clock, FolderLock, FolderSync, Zap, Bot, Loader2 } from "lucide-react";
|
||||
import { Save, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CHANNEL_TYPES } from "@/constants/channels";
|
||||
import { MultiUserPicker } from "@/components/shared/multi-user-picker";
|
||||
import type { TeamData, TeamAccessSettings, TeamNotifyConfig, EscalationMode, EscalationAction } from "@/types/team";
|
||||
import { useTeams } from "./hooks/use-teams";
|
||||
import { TeamNotificationsSection } from "./team-notifications-section";
|
||||
import { TeamAccessControlSection } from "./team-access-control-section";
|
||||
import { TeamOrchestrationSection } from "./team-orchestration-section";
|
||||
|
||||
interface TeamSettingsTabProps {
|
||||
teamId: string;
|
||||
@@ -16,55 +15,10 @@ interface TeamSettingsTabProps {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** Generic multi-select with static options (used for channel type selection). */
|
||||
function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
options: { value: string; label?: string }[];
|
||||
selected: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Combobox
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
if (val && !selected.includes(val)) {
|
||||
onChange([...selected, val]);
|
||||
}
|
||||
}}
|
||||
options={options.filter((o) => !selected.includes(o.value))}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="gap-1 pr-1">
|
||||
{options.find((o) => o.value === id)?.label ?? id}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter((s) => s !== id))}
|
||||
className="ml-0.5 cursor-pointer rounded-full p-0.5 hover:bg-muted"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const { updateTeamSettings } = useTeams();
|
||||
|
||||
// Parse initial settings
|
||||
const initial = (team.settings ?? {}) as TeamAccessSettings;
|
||||
const [allowUserIds, setAllowUserIds] = useState<string[]>(initial.allow_user_ids ?? []);
|
||||
const [denyUserIds, setDenyUserIds] = useState<string[]>(initial.deny_user_ids ?? []);
|
||||
@@ -89,10 +43,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
const [followupInterval, setFollowupInterval] = useState<number>(initial.followup_interval_minutes ?? 30);
|
||||
const [followupMaxReminders, setFollowupMaxReminders] = useState<number>(initial.followup_max_reminders ?? 0);
|
||||
const [workspaceScope, setWorkspaceScope] = useState<string>(initial.workspace_scope ?? "isolated");
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Reset when team changes
|
||||
useEffect(() => {
|
||||
const s = (team.settings ?? {}) as TeamAccessSettings;
|
||||
setAllowUserIds(s.allow_user_ids ?? []);
|
||||
@@ -134,16 +86,13 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
failed: notifyFailed,
|
||||
slow_tool: notifySlowTool,
|
||||
mode: notifyMode,
|
||||
completed: notifyCompleted,
|
||||
commented: notifyCommented,
|
||||
new_task: notifyNewTask,
|
||||
};
|
||||
notifications.completed = notifyCompleted;
|
||||
notifications.commented = notifyCommented;
|
||||
notifications.new_task = notifyNewTask;
|
||||
settings.notifications = notifications;
|
||||
if (memberRequestsEnabled) {
|
||||
settings.member_requests = {
|
||||
enabled: true,
|
||||
auto_dispatch: memberRequestsAutoDispatch,
|
||||
};
|
||||
settings.member_requests = { enabled: true, auto_dispatch: memberRequestsAutoDispatch };
|
||||
}
|
||||
if (escalationMode) {
|
||||
settings.escalation_mode = escalationMode;
|
||||
@@ -165,293 +114,33 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Notifications */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.notifications")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-blue-500/5 to-orange-500/5 p-4 space-y-3">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-blue-500/10 p-2.5 text-blue-600 dark:text-blue-400">
|
||||
<Bell className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyDispatched")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.notifyDispatchedHint")}</p>
|
||||
</div>
|
||||
<Switch checked={notifyDispatched} onCheckedChange={setNotifyDispatched} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyProgress")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.notifyProgressHint")}</p>
|
||||
</div>
|
||||
<Switch checked={notifyProgress} onCheckedChange={setNotifyProgress} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyFailed")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.notifyFailedHint")}</p>
|
||||
</div>
|
||||
<Switch checked={notifyFailed} onCheckedChange={setNotifyFailed} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyCompleted")}</span>
|
||||
</div>
|
||||
<Switch checked={notifyCompleted} onCheckedChange={setNotifyCompleted} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyCommented")}</span>
|
||||
</div>
|
||||
<Switch checked={notifyCommented} onCheckedChange={setNotifyCommented} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyNewTask")}</span>
|
||||
</div>
|
||||
<Switch checked={notifyNewTask} onCheckedChange={setNotifyNewTask} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifySlowTool")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.notifySlowToolHint")}</p>
|
||||
</div>
|
||||
<Switch checked={notifySlowTool} onCheckedChange={setNotifySlowTool} />
|
||||
</div>
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<span className="text-sm font-semibold">{t("settings.notifyMode")}</span>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{([
|
||||
{ value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" },
|
||||
{ value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" },
|
||||
]).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setNotifyMode(opt.value)}
|
||||
className={
|
||||
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
|
||||
(notifyMode === opt.value
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t(`settings.${opt.descKey}`)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notifyMode === "leader" && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
⚠️ {t("settings.notifyModeLeaderWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TeamNotificationsSection
|
||||
notifyDispatched={notifyDispatched} setNotifyDispatched={setNotifyDispatched}
|
||||
notifyProgress={notifyProgress} setNotifyProgress={setNotifyProgress}
|
||||
notifyFailed={notifyFailed} setNotifyFailed={setNotifyFailed}
|
||||
notifyCompleted={notifyCompleted} setNotifyCompleted={setNotifyCompleted}
|
||||
notifyCommented={notifyCommented} setNotifyCommented={setNotifyCommented}
|
||||
notifyNewTask={notifyNewTask} setNotifyNewTask={setNotifyNewTask}
|
||||
notifySlowTool={notifySlowTool} setNotifySlowTool={setNotifySlowTool}
|
||||
notifyMode={notifyMode} setNotifyMode={setNotifyMode}
|
||||
/>
|
||||
|
||||
{/* Workspace Scope */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.workspace")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-emerald-500/5 to-teal-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2.5 text-emerald-600 dark:text-emerald-400">
|
||||
<FolderSync className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-semibold">{t("settings.workspaceScope")}</span>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.workspaceScopeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{([
|
||||
{ value: "isolated", Icon: FolderLock, labelKey: "workspaceScopeIsolated", descKey: "workspaceScopeIsolatedDesc" },
|
||||
{ value: "shared", Icon: FolderSync, labelKey: "workspaceScopeShared", descKey: "workspaceScopeSharedDesc" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setWorkspaceScope(opt.value)}
|
||||
className={
|
||||
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
|
||||
(workspaceScope === opt.value
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t(`settings.${opt.descKey}`)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TeamOrchestrationSection
|
||||
workspaceScope={workspaceScope} setWorkspaceScope={setWorkspaceScope}
|
||||
memberRequestsEnabled={memberRequestsEnabled} setMemberRequestsEnabled={setMemberRequestsEnabled}
|
||||
memberRequestsAutoDispatch={memberRequestsAutoDispatch} setMemberRequestsAutoDispatch={setMemberRequestsAutoDispatch}
|
||||
blockerEscalationEnabled={blockerEscalationEnabled} setBlockerEscalationEnabled={setBlockerEscalationEnabled}
|
||||
followupInterval={followupInterval} setFollowupInterval={setFollowupInterval}
|
||||
followupMaxReminders={followupMaxReminders} setFollowupMaxReminders={setFollowupMaxReminders}
|
||||
/>
|
||||
|
||||
{/* Member Requests */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.memberRequests")}</h3>
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.memberRequestsEnabled")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.memberRequestsEnabledDesc")}</p>
|
||||
</div>
|
||||
<Switch checked={memberRequestsEnabled} onCheckedChange={setMemberRequestsEnabled} />
|
||||
</div>
|
||||
{memberRequestsEnabled && (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.memberRequestsAutoDispatch")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.memberRequestsAutoDispatchDesc")}</p>
|
||||
</div>
|
||||
<Switch checked={memberRequestsAutoDispatch} onCheckedChange={setMemberRequestsAutoDispatch} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocker Escalation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.blockerEscalation")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-orange-500/5 to-red-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-orange-500/10 p-2.5 text-orange-600 dark:text-orange-400">
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-semibold">{t("settings.blockerEscalationEnabled")}</span>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.blockerEscalationHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={blockerEscalationEnabled} onCheckedChange={setBlockerEscalationEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Follow-up Reminders */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.followupReminders")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-amber-500/5 to-yellow-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-amber-500/10 p-2.5 text-amber-600 dark:text-amber-400">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.followupInterval")}</label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.followupIntervalHint")}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={followupInterval}
|
||||
onChange={(e) => setFollowupInterval(Math.max(1, parseInt(e.target.value) || 30))}
|
||||
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.followupMaxReminders")}</label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.followupMaxRemindersHint")}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={followupMaxReminders}
|
||||
onChange={(e) => setFollowupMaxReminders(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Access Control */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.allowedUsers")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.allowedUsersHint")}
|
||||
</p>
|
||||
<MultiUserPicker
|
||||
value={allowUserIds}
|
||||
onChange={setAllowUserIds}
|
||||
placeholder={t("settings.searchUsers")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.deniedUsers")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.deniedUsersHint")}
|
||||
</p>
|
||||
<MultiUserPicker
|
||||
value={denyUserIds}
|
||||
onChange={setDenyUserIds}
|
||||
placeholder={t("settings.searchUsers")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Restrictions */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.channelRestrictions")}</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.allowedChannels")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.allowedChannelsHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={channelOptions}
|
||||
selected={allowChannels}
|
||||
onChange={setAllowChannels}
|
||||
placeholder={t("settings.selectChannel")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.deniedChannels")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.deniedChannelsHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={channelOptions}
|
||||
selected={denyChannels}
|
||||
onChange={setDenyChannels}
|
||||
placeholder={t("settings.selectChannel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TeamAccessControlSection
|
||||
allowUserIds={allowUserIds} setAllowUserIds={setAllowUserIds}
|
||||
denyUserIds={denyUserIds} setDenyUserIds={setDenyUserIds}
|
||||
allowChannels={allowChannels} setAllowChannels={setAllowChannels}
|
||||
denyChannels={denyChannels} setDenyChannels={setDenyChannels}
|
||||
channelOptions={channelOptions}
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -460,7 +149,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
{saving ? t("settings.saving") : t("settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,18 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChevronRight, ChevronDown, Copy, Check, Download, CircleCheck, CircleX, Loader, CircleMinus, Square } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Copy, Check, Download, Square } from "lucide-react";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Events } from "@/api/protocol";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { SpanTreeNode, StatusBadge, buildSpanTree } from "./trace-span-tree-node";
|
||||
import { TracePreviewBlock } from "./trace-preview-block";
|
||||
import type { TraceData, SpanData } from "./hooks/use-traces";
|
||||
import type { AgentEventPayload } from "@/types/chat";
|
||||
|
||||
hljs.registerLanguage("json", json);
|
||||
|
||||
interface SpanNode {
|
||||
span: SpanData;
|
||||
children: SpanNode[];
|
||||
}
|
||||
|
||||
function buildSpanTree(spans: SpanData[]): SpanNode[] {
|
||||
const map = new Map<string, SpanNode>();
|
||||
const roots: SpanNode[] = [];
|
||||
|
||||
// Create nodes
|
||||
for (const span of spans) {
|
||||
map.set(span.id, { span, children: [] });
|
||||
}
|
||||
|
||||
// Link parent → children
|
||||
for (const span of spans) {
|
||||
const node = map.get(span.id)!;
|
||||
if (span.parent_span_id && map.has(span.parent_span_id)) {
|
||||
map.get(span.parent_span_id)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
interface TraceDetailDialogProps {
|
||||
traceId: string;
|
||||
onClose: () => void;
|
||||
@@ -72,65 +37,38 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace,
|
||||
const blob = await http.downloadBlob(`/v1/traces/${traceId}/export`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `trace-${traceId.slice(0, 8)}.json.gz`;
|
||||
a.click();
|
||||
a.href = url; a.download = `trace-${traceId.slice(0, 8)}.json.gz`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// silently fail — user sees no download
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
} catch { /* silently fail */ } finally { setExporting(false); }
|
||||
}, [http, traceId]);
|
||||
|
||||
const fetchTrace = useCallback(() => {
|
||||
getTrace(traceId).then((result) => {
|
||||
if (result) {
|
||||
setTrace(result.trace);
|
||||
setSpans(result.spans ?? []);
|
||||
}
|
||||
if (result) { setTrace(result.trace); setSpans(result.spans ?? []); }
|
||||
});
|
||||
}, [traceId, getTrace]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getTrace(traceId)
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
setTrace(result.trace);
|
||||
setSpans(result.spans ?? []);
|
||||
}
|
||||
})
|
||||
.then((result) => { if (result) { setTrace(result.trace); setSpans(result.spans ?? []); } })
|
||||
.finally(() => setLoading(false));
|
||||
}, [traceId, getTrace]);
|
||||
|
||||
// Auto-refetch when trace aggregates update (spans flushed every ~5s)
|
||||
useWsEvent(
|
||||
Events.TRACE_UPDATED,
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const data = payload as { trace_ids?: string[] };
|
||||
if (data?.trace_ids?.includes(traceId)) {
|
||||
fetchTrace();
|
||||
}
|
||||
},
|
||||
[traceId, fetchTrace],
|
||||
),
|
||||
);
|
||||
// Auto-refetch when trace aggregates update
|
||||
useWsEvent(Events.TRACE_UPDATED, useCallback(
|
||||
(payload: unknown) => { const data = payload as { trace_ids?: string[] }; if (data?.trace_ids?.includes(traceId)) fetchTrace(); },
|
||||
[traceId, fetchTrace],
|
||||
));
|
||||
|
||||
// Also refetch on run completion (final status + output)
|
||||
useWsEvent(
|
||||
Events.AGENT,
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const event = payload as AgentEventPayload;
|
||||
if (event?.type === "run.completed" || event?.type === "run.failed" || event?.type === "run.cancelled") {
|
||||
fetchTrace();
|
||||
}
|
||||
},
|
||||
[fetchTrace],
|
||||
),
|
||||
);
|
||||
// Refetch on run completion
|
||||
useWsEvent(Events.AGENT, useCallback(
|
||||
(payload: unknown) => {
|
||||
const event = payload as AgentEventPayload;
|
||||
if (event?.type === "run.completed" || event?.type === "run.failed" || event?.type === "run.cancelled") fetchTrace();
|
||||
},
|
||||
[fetchTrace],
|
||||
));
|
||||
|
||||
const spanTree = useMemo(() => buildSpanTree(spans), [spans]);
|
||||
|
||||
@@ -140,366 +78,82 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace,
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 pr-8">
|
||||
{t("detail.title")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(traceId)}
|
||||
className="ml-auto flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<button type="button" onClick={() => copy(traceId)} className="ml-auto flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{t("detail.copyTraceId")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || !trace}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{exporting ? (
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<button type="button" onClick={handleExport} disabled={exporting || !trace} className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50">
|
||||
{exporting ? <div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" /> : <Download className="h-3.5 w-3.5" />}
|
||||
{t("detail.export")}
|
||||
</button>
|
||||
{trace && (trace.status === "running") && onAbortRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onAbortRun(trace, e)}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-md bg-destructive px-2 py-1 text-xs text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
{t("detail.stopRun")}
|
||||
{trace && trace.status === "running" && onAbortRun && (
|
||||
<button type="button" onClick={(e) => onAbortRun(trace, e)} className="flex cursor-pointer items-center gap-1 rounded-md bg-destructive px-2 py-1 text-xs text-destructive-foreground transition-colors hover:bg-destructive/90">
|
||||
<Square className="h-3.5 w-3.5" />{t("detail.stopRun")}
|
||||
</button>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6">
|
||||
{loading && !trace ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
) : !trace ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("detail.notFound")}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Trace summary */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.name")}</span>{" "}
|
||||
<span className="font-medium">{trace.name || t("unnamed")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.status")}</span>{" "}
|
||||
<StatusBadge status={trace.status} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.duration")}</span>{" "}
|
||||
{formatDuration(trace.duration_ms || computeDurationMs(trace.start_time, trace.end_time))}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.channel")}</span>{" "}
|
||||
{trace.channel || "—"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.tokens")}</span>{" "}
|
||||
{formatTokens(trace.total_input_tokens)} in / {formatTokens(trace.total_output_tokens)} out
|
||||
{((trace.metadata?.total_cache_read_tokens ?? 0) > 0 || (trace.metadata?.total_cache_creation_tokens ?? 0) > 0) && (
|
||||
<span className="ml-1 text-xs">
|
||||
{(trace.metadata?.total_cache_read_tokens ?? 0) > 0 && (
|
||||
<span className="text-green-400">{formatTokens(trace.metadata!.total_cache_read_tokens!)} {t("span.cached")}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.spans")}</span>{" "}
|
||||
{trace.span_count} ({trace.llm_call_count} {t("detail.llmCalls")}, {trace.tool_call_count} {t("detail.toolCalls")})
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.started")}</span>{" "}
|
||||
{formatDate(trace.start_time, tz)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.createdAt")}</span>{" "}
|
||||
{formatDate(trace.created_at, tz)}
|
||||
</div>
|
||||
{trace.parent_trace_id && (
|
||||
{loading && !trace ? (
|
||||
<div className="flex items-center justify-center py-12"><div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" /></div>
|
||||
) : !trace ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("detail.notFound")}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<TraceSummaryGrid trace={trace} tz={tz} onNavigateTrace={onNavigateTrace} />
|
||||
|
||||
{trace.input_preview && <TracePreviewBlock label={t("detail.input")} content={trace.input_preview} />}
|
||||
{trace.output_preview && <TracePreviewBlock label={t("detail.output")} content={trace.output_preview} />}
|
||||
{trace.error && (
|
||||
<div className="rounded-md border border-red-400/30 bg-red-500/10 p-3">
|
||||
<p className="break-all text-sm text-red-300">{trace.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{spans.length > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.delegatedFrom")}</span>{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer font-mono text-xs text-primary hover:underline"
|
||||
onClick={() => onNavigateTrace?.(trace.parent_trace_id!)}
|
||||
>
|
||||
{trace.parent_trace_id.slice(0, 8)}...
|
||||
</button>
|
||||
<h4 className="mb-2 text-sm font-medium">{t("detail.spansCount", { count: spans.length })}</h4>
|
||||
<div className="space-y-1">
|
||||
{spanTree.map((node) => <SpanTreeNode key={node.span.id} node={node} depth={0} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trace.input_preview && (
|
||||
<PreviewBlock label={t("detail.input")} content={trace.input_preview} />
|
||||
)}
|
||||
|
||||
{trace.output_preview && (
|
||||
<PreviewBlock label={t("detail.output")} content={trace.output_preview} />
|
||||
)}
|
||||
|
||||
{trace.error && (
|
||||
<div className="rounded-md border border-red-400/30 bg-red-500/10 p-3">
|
||||
<p className="break-all text-sm text-red-300">{trace.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Span tree */}
|
||||
{spans.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium">{t("detail.spansCount", { count: spans.length })}</h4>
|
||||
<div className="space-y-1">
|
||||
{spanTree.map((node) => (
|
||||
<SpanTreeNode key={node.span.id} node={node} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
|
||||
/** Trace metadata summary grid */
|
||||
function TraceSummaryGrid({ trace, tz, onNavigateTrace }: { trace: TraceData; tz: string; onNavigateTrace?: (id: string) => void }) {
|
||||
const { t } = useTranslation("traces");
|
||||
const tz = useUiStore((s) => s.timezone);
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const { span, children } = node;
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="mt-1.5 min-w-0 rounded-md border text-sm"
|
||||
style={{ marginLeft: depth * 16 }}
|
||||
>
|
||||
<div className="flex w-full items-center gap-1 px-2 py-2">
|
||||
{/* Tree toggle */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-muted"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Span info row - clickable for detail */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 cursor-pointer items-center gap-2 text-left hover:opacity-80"
|
||||
onClick={() => setDetailOpen(!detailOpen)}
|
||||
>
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{span.span_type}
|
||||
</Badge>
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{span.name || span.tool_name || "span"}
|
||||
</span>
|
||||
{(span.input_tokens > 0 || span.output_tokens > 0) && (
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
||||
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
|
||||
{(span.metadata?.cache_read_tokens ?? 0) > 0 && (
|
||||
<span className="ml-1 text-green-400">
|
||||
({formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cached")})
|
||||
</span>
|
||||
)}
|
||||
{(span.metadata?.thinking_tokens ?? 0) > 0 && (
|
||||
<span className="ml-1 text-orange-400">
|
||||
({formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{span.created_at && (
|
||||
<Badge variant="outline" className="hidden shrink-0 text-xs text-muted-foreground lg:inline-flex">
|
||||
{formatDate(span.created_at, tz)}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDuration(span.duration_ms || computeDurationMs(span.start_time, span.end_time))}
|
||||
</Badge>
|
||||
<StatusBadge status={span.status} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail panel */}
|
||||
{detailOpen && (
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto border-t px-3 py-2">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
{span.start_time && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("span.startTime")}</span> {formatDate(span.start_time, tz)}
|
||||
</div>
|
||||
)}
|
||||
{span.end_time && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("span.endTime")}</span> {formatDate(span.end_time, tz)}
|
||||
</div>
|
||||
)}
|
||||
{span.model && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("span.model")}</span> {span.provider}/{span.model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(span.input_tokens > 0 || span.output_tokens > 0) && (
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">{t("span.tokens")}</span>{" "}
|
||||
{formatTokens(span.input_tokens)} in / {formatTokens(span.output_tokens)} out
|
||||
{((span.metadata?.cache_creation_tokens ?? 0) > 0 || (span.metadata?.cache_read_tokens ?? 0) > 0) && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
(cache:
|
||||
{(span.metadata?.cache_read_tokens ?? 0) > 0 && (
|
||||
<span className="ml-1 text-green-400">{formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cacheRead")}</span>
|
||||
)}
|
||||
{(span.metadata?.cache_creation_tokens ?? 0) > 0 && (
|
||||
<span className="ml-1 text-yellow-400">{formatTokens(span.metadata!.cache_creation_tokens!)} {t("span.cacheWrite")}</span>
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
{(span.metadata?.thinking_tokens ?? 0) > 0 && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
(<span className="text-orange-400">{formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")}</span>)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{span.metadata?.reasoning && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>{t("span.reasoning")}</span>{" "}
|
||||
{span.metadata.reasoning.requested_effort ? (
|
||||
<span>
|
||||
{t("span.requested")} {span.metadata.reasoning.requested_effort}
|
||||
</span>
|
||||
) : null}
|
||||
{span.metadata.reasoning.source ? (
|
||||
<span className="ml-2">
|
||||
{t("span.source")} {t(`span.sourceValue.${span.metadata.reasoning.source}`)}
|
||||
</span>
|
||||
) : null}
|
||||
{span.metadata.reasoning.effective_effort ? (
|
||||
<span className="ml-2">
|
||||
{t("span.effective")} {span.metadata.reasoning.effective_effort}
|
||||
</span>
|
||||
) : null}
|
||||
{span.metadata.reasoning.fallback ? (
|
||||
<span className="ml-2">
|
||||
{t("span.fallback")} {span.metadata.reasoning.fallback}
|
||||
</span>
|
||||
) : null}
|
||||
{span.metadata.reasoning.used_provider_default ? (
|
||||
<span className="ml-2">{t("span.modelDefault")}</span>
|
||||
) : null}
|
||||
{span.metadata.reasoning.reason ? (
|
||||
<div className="mt-1">{span.metadata.reasoning.reason}</div>
|
||||
) : null}
|
||||
{span.metadata.reasoning.supported_levels?.length ? (
|
||||
<div className="mt-1">
|
||||
{t("span.supportedLevels")} {span.metadata.reasoning.supported_levels.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{span.input_preview && (
|
||||
<PreviewBlock label={t("span.input")} content={span.input_preview} />
|
||||
)}
|
||||
{span.output_preview && (
|
||||
<PreviewBlock label={t("span.output")} content={span.output_preview} />
|
||||
)}
|
||||
{span.error && (
|
||||
<p className="break-all text-xs text-red-300">{span.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<div><span className="text-muted-foreground">{t("detail.name")}</span> <span className="font-medium">{trace.name || t("unnamed")}</span></div>
|
||||
<div><span className="text-muted-foreground">{t("detail.status")}</span> <StatusBadge status={trace.status} /></div>
|
||||
<div><span className="text-muted-foreground">{t("detail.duration")}</span> {formatDuration(trace.duration_ms || computeDurationMs(trace.start_time, trace.end_time))}</div>
|
||||
<div><span className="text-muted-foreground">{t("detail.channel")}</span> {trace.channel || "—"}</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.tokens")}</span>{" "}
|
||||
{formatTokens(trace.total_input_tokens)} in / {formatTokens(trace.total_output_tokens)} out
|
||||
{((trace.metadata?.total_cache_read_tokens ?? 0) > 0) && (
|
||||
<span className="ml-1 text-xs text-green-400">{formatTokens(trace.metadata!.total_cache_read_tokens!)} {t("span.cached")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Render children when tree is expanded */}
|
||||
{expanded && children.map((child) => (
|
||||
<SpanTreeNode key={child.span.id} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Try to pretty-print JSON; returns { text, isJson }. */
|
||||
function formatPreview(text: string): { text: string; isJson: boolean } {
|
||||
const trimmed = text.trim();
|
||||
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
||||
try {
|
||||
const pretty = JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||
return { text: pretty, isJson: true };
|
||||
} catch {
|
||||
// not valid JSON
|
||||
}
|
||||
}
|
||||
return { text, isJson: false };
|
||||
}
|
||||
|
||||
function PreviewBlock({ label, content }: { label: string; content: string }) {
|
||||
const { t } = useTranslation("traces");
|
||||
const { copied, copy } = useClipboard();
|
||||
const { text: formatted, isJson } = useMemo(() => formatPreview(content), [content]);
|
||||
const highlightedHtml = useMemo(() => {
|
||||
if (!isJson) return null;
|
||||
return hljs.highlight(formatted, { language: "json" }).value;
|
||||
}, [formatted, isJson]);
|
||||
|
||||
return (
|
||||
<div className="relative rounded-md border p-3">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(content)}
|
||||
className="absolute right-2 top-2 flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
{t("detail.copy")}
|
||||
</button>
|
||||
{highlightedHtml ? (
|
||||
<pre
|
||||
className="hljs mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]"
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]">{formatted}</pre>
|
||||
<div><span className="text-muted-foreground">{t("detail.spans")}</span> {trace.span_count} ({trace.llm_call_count} {t("detail.llmCalls")}, {trace.tool_call_count} {t("detail.toolCalls")})</div>
|
||||
<div><span className="text-muted-foreground">{t("detail.started")}</span> {formatDate(trace.start_time, tz)}</div>
|
||||
<div><span className="text-muted-foreground">{t("detail.createdAt")}</span> {formatDate(trace.created_at, tz)}</div>
|
||||
{trace.parent_trace_id && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("detail.delegatedFrom")}</span>{" "}
|
||||
<button type="button" className="cursor-pointer font-mono text-xs text-primary hover:underline" onClick={() => onNavigateTrace?.(trace.parent_trace_id!)}>
|
||||
{trace.parent_trace_id.slice(0, 8)}...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const isOk = status === "ok" || status === "success" || status === "completed";
|
||||
const isError = status === "error" || status === "failed";
|
||||
const isRunning = status === "running" || status === "pending";
|
||||
|
||||
const variant = isOk ? "success" : isError ? "destructive" : isRunning ? "info" : "secondary";
|
||||
const Icon = isOk ? CircleCheck : isError ? CircleX : isRunning ? Loader : CircleMinus;
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="text-xs">
|
||||
<Icon className={"h-3 w-3 sm:hidden" + (isRunning ? " animate-spin" : "")} />
|
||||
<span className="hidden sm:inline">{status || "unknown"}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
|
||||
hljs.registerLanguage("json", json);
|
||||
|
||||
/** Try to pretty-print JSON; returns { text, isJson }. */
|
||||
function formatPreview(text: string): { text: string; isJson: boolean } {
|
||||
const trimmed = text.trim();
|
||||
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
||||
try {
|
||||
return { text: JSON.stringify(JSON.parse(trimmed), null, 2), isJson: true };
|
||||
} catch { /* not valid JSON */ }
|
||||
}
|
||||
return { text, isJson: false };
|
||||
}
|
||||
|
||||
export function TracePreviewBlock({ label, content }: { label: string; content: string }) {
|
||||
const { t } = useTranslation("traces");
|
||||
const { copied, copy } = useClipboard();
|
||||
const { text: formatted, isJson } = useMemo(() => formatPreview(content), [content]);
|
||||
const highlightedHtml = useMemo(() => {
|
||||
if (!isJson) return null;
|
||||
return hljs.highlight(formatted, { language: "json" }).value;
|
||||
}, [formatted, isJson]);
|
||||
|
||||
return (
|
||||
<div className="relative rounded-md border p-3">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(content)}
|
||||
className="absolute right-2 top-2 flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
{t("detail.copy")}
|
||||
</button>
|
||||
{highlightedHtml ? (
|
||||
<pre className="hljs mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]" dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
|
||||
) : (
|
||||
<pre className="mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]">{formatted}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChevronRight, ChevronDown, CircleCheck, CircleX, Loader, CircleMinus } from "lucide-react";
|
||||
import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { TracePreviewBlock } from "./trace-preview-block";
|
||||
import type { SpanData } from "./hooks/use-traces";
|
||||
|
||||
export interface SpanNode {
|
||||
span: SpanData;
|
||||
children: SpanNode[];
|
||||
}
|
||||
|
||||
/** Build a parent→children tree from flat span list */
|
||||
export function buildSpanTree(spans: SpanData[]): SpanNode[] {
|
||||
const map = new Map<string, SpanNode>();
|
||||
const roots: SpanNode[] = [];
|
||||
for (const span of spans) map.set(span.id, { span, children: [] });
|
||||
for (const span of spans) {
|
||||
const node = map.get(span.id)!;
|
||||
if (span.parent_span_id && map.has(span.parent_span_id)) {
|
||||
map.get(span.parent_span_id)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const isOk = status === "ok" || status === "success" || status === "completed";
|
||||
const isError = status === "error" || status === "failed";
|
||||
const isRunning = status === "running" || status === "pending";
|
||||
const variant = isOk ? "success" : isError ? "destructive" : isRunning ? "info" : "secondary";
|
||||
const Icon = isOk ? CircleCheck : isError ? CircleX : isRunning ? Loader : CircleMinus;
|
||||
return (
|
||||
<Badge variant={variant} className="text-xs">
|
||||
<Icon className={"h-3 w-3 sm:hidden" + (isRunning ? " animate-spin" : "")} />
|
||||
<span className="hidden sm:inline">{status || "unknown"}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
|
||||
const { t } = useTranslation("traces");
|
||||
const tz = useUiStore((s) => s.timezone);
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const { span, children } = node;
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-1.5 min-w-0 rounded-md border text-sm" style={{ marginLeft: depth * 16 }}>
|
||||
<div className="flex w-full items-center gap-1 px-2 py-2">
|
||||
{hasChildren ? (
|
||||
<button type="button" className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-muted" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
<button type="button" className="flex flex-1 cursor-pointer items-center gap-2 text-left hover:opacity-80" onClick={() => setDetailOpen(!detailOpen)}>
|
||||
<Badge variant="outline" className="shrink-0 text-xs">{span.span_type}</Badge>
|
||||
<span className="flex-1 truncate font-medium">{span.name || span.tool_name || "span"}</span>
|
||||
{(span.input_tokens > 0 || span.output_tokens > 0) && (
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
||||
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
|
||||
{(span.metadata?.cache_read_tokens ?? 0) > 0 && <span className="ml-1 text-green-400">({formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cached")})</span>}
|
||||
{(span.metadata?.thinking_tokens ?? 0) > 0 && <span className="ml-1 text-orange-400">({formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")})</span>}
|
||||
</span>
|
||||
)}
|
||||
{span.created_at && <Badge variant="outline" className="hidden shrink-0 text-xs text-muted-foreground lg:inline-flex">{formatDate(span.created_at, tz)}</Badge>}
|
||||
<Badge variant="outline" className="shrink-0 text-xs text-muted-foreground">{formatDuration(span.duration_ms || computeDurationMs(span.start_time, span.end_time))}</Badge>
|
||||
<StatusBadge status={span.status} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{detailOpen && <SpanDetailPanel span={span} />}
|
||||
</div>
|
||||
|
||||
{expanded && children.map((child) => (
|
||||
<SpanTreeNode key={child.span.id} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Expanded detail panel for a single span */
|
||||
function SpanDetailPanel({ span }: { span: SpanData }) {
|
||||
const { t } = useTranslation("traces");
|
||||
const tz = useUiStore((s) => s.timezone);
|
||||
const reasoning = span.metadata?.reasoning;
|
||||
|
||||
return (
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto border-t px-3 py-2">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
{span.start_time && <div><span className="text-muted-foreground">{t("span.startTime")}</span> {formatDate(span.start_time, tz)}</div>}
|
||||
{span.end_time && <div><span className="text-muted-foreground">{t("span.endTime")}</span> {formatDate(span.end_time, tz)}</div>}
|
||||
{span.model && <div><span className="text-muted-foreground">{t("span.model")}</span> {span.provider}/{span.model}</div>}
|
||||
</div>
|
||||
{(span.input_tokens > 0 || span.output_tokens > 0) && (
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">{t("span.tokens")}</span>{" "}
|
||||
{formatTokens(span.input_tokens)} in / {formatTokens(span.output_tokens)} out
|
||||
{((span.metadata?.cache_creation_tokens ?? 0) > 0 || (span.metadata?.cache_read_tokens ?? 0) > 0) && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
(cache:
|
||||
{(span.metadata?.cache_read_tokens ?? 0) > 0 && <span className="ml-1 text-green-400">{formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cacheRead")}</span>}
|
||||
{(span.metadata?.cache_creation_tokens ?? 0) > 0 && <span className="ml-1 text-yellow-400">{formatTokens(span.metadata!.cache_creation_tokens!)} {t("span.cacheWrite")}</span>}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
{(span.metadata?.thinking_tokens ?? 0) > 0 && (
|
||||
<span className="ml-2 text-muted-foreground">(<span className="text-orange-400">{formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")}</span>)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{reasoning && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>{t("span.reasoning")}</span>{" "}
|
||||
{reasoning.requested_effort ? <span>{t("span.requested")} {reasoning.requested_effort}</span> : null}
|
||||
{reasoning.source ? <span className="ml-2">{t("span.source")} {t(`span.sourceValue.${reasoning.source}`)}</span> : null}
|
||||
{reasoning.effective_effort ? <span className="ml-2">{t("span.effective")} {reasoning.effective_effort}</span> : null}
|
||||
{reasoning.fallback ? <span className="ml-2">{t("span.fallback")} {reasoning.fallback}</span> : null}
|
||||
{reasoning.used_provider_default ? <span className="ml-2">{t("span.modelDefault")}</span> : null}
|
||||
{reasoning.reason ? <div className="mt-1">{reasoning.reason}</div> : null}
|
||||
{reasoning.supported_levels?.length ? <div className="mt-1">{t("span.supportedLevels")} {reasoning.supported_levels.join(", ")}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
{span.input_preview && <TracePreviewBlock label={t("span.input")} content={span.input_preview} />}
|
||||
{span.output_preview && <TracePreviewBlock label={t("span.output")} content={span.output_preview} />}
|
||||
{span.error && <p className="break-all text-xs text-red-300">{span.error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user