diff --git a/ui/web/src/components/chat/rich-content-parser.ts b/ui/web/src/components/chat/rich-content-parser.ts new file mode 100644 index 00000000..53c42dce --- /dev/null +++ b/ui/web/src/components/chat/rich-content-parser.ts @@ -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. + */ + +// , , , , +const MEDIA_TAG_RE = //g; + +// content +const FILE_BLOCK_RE = /([\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(); + 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(); + const result: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const trimmed = line.trim(); + const isStandaloneLink = (trimmed.startsWith("![") || trimmed.startsWith("[")) && + trimmed.includes("](/") && 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"); +} diff --git a/ui/web/src/components/chat/rich-content.tsx b/ui/web/src/components/chat/rich-content.tsx index 195b51af..430ebab8 100644 --- a/ui/web/src/components/chat/rich-content.tsx +++ b/ui/web/src/components/chat/rich-content.tsx @@ -18,109 +18,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; - -// --- Patterns to detect special blocks --- - -// , , , , -const MEDIA_TAG_RE = //g; - -// content -const FILE_BLOCK_RE = /([\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(); - 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(); - const result: string[] = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i] ?? ""; - const trimmed = line.trim(); - const isStandaloneLink = (trimmed.startsWith("![") || trimmed.startsWith("[")) && - trimmed.includes("](/") && 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 { diff --git a/ui/web/src/components/layout/system-settings-compaction-card.tsx b/ui/web/src/components/layout/system-settings-compaction-card.tsx new file mode 100644 index 00000000..37bf7af6 --- /dev/null +++ b/ui/web/src/components/layout/system-settings-compaction-card.tsx @@ -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 ( + + + {t("compaction.title")} + {t("compaction.description")} + + +
+ +
+ +
+
+ +
+ +

{t("compaction.thresholdHint")}

+
+
+ setCompThreshold(e.target.value)} placeholder="200" min={1} className="w-24 shrink-0 text-base md:text-sm" /> +
+ +
+
+ +
+ +

{t("compaction.keepRecentHint")}

+
+
+ setCompKeepRecent(e.target.value)} placeholder="40" min={1} className="w-24 shrink-0 text-base md:text-sm" /> +
+ +
+
+ +
+ +

{t("compaction.maxTokensHint")}

+
+
+ setCompMaxTokens(e.target.value)} placeholder="4096" min={256} className="w-24 shrink-0 text-base md:text-sm" /> +
+ +
+ + {t("compaction.info")} +
+
+
+ ); +} diff --git a/ui/web/src/components/layout/system-settings-constants.ts b/ui/web/src/components/layout/system-settings-constants.ts new file mode 100644 index 00000000..504310ea --- /dev/null +++ b/ui/web/src/components/layout/system-settings-constants.ts @@ -0,0 +1,59 @@ +/** Curated 1536-dimension embedding models per provider type. */ +export const EMBEDDING_MODELS: Record = { + 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"; +} diff --git a/ui/web/src/components/layout/system-settings-embedding-card.tsx b/ui/web/src/components/layout/system-settings-embedding-card.tsx new file mode 100644 index 00000000..e2a90512 --- /dev/null +++ b/ui/web/src/components/layout/system-settings-embedding-card.tsx @@ -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 ( + + + + + {t("embedding.title")} + + {t("embedding.description")} + + +
+ +
+

{t("embedding.importance")}

+

{t("embedding.supportedProviders")}

+
+
+ + { 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")} + /> + +
+ + {verifyResult && ( + + {verifyResult.valid ? ( + <> + {verifyResult.dimension_mismatch ? : } + {verifyResult.dimension_mismatch + ? t("embedding.dimensionsMismatch", { count: verifyResult.dimensions }) + : t("embedding.dimensions", { count: verifyResult.dimensions })} + + ) : ( + <>{verifyResult.error || t("embedding.verifyFailed")} + )} + + )} +
+ +
+
+ + setEmbMaxChunkLen(e.target.value)} className="text-base md:text-sm" /> +

{t("embedding.maxChunkLenHint")}

+
+
+ + setEmbChunkOverlap(e.target.value)} className="text-base md:text-sm" /> +

{t("embedding.chunkOverlapHint")}

+
+
+
+
+ ); +} diff --git a/ui/web/src/components/layout/system-settings-modal.tsx b/ui/web/src/components/layout/system-settings-modal.tsx index db9f0ff0..ce36a27c 100644 --- a/ui/web/src/components/layout/system-settings-modal.tsx +++ b/ui/web/src/components/layout/system-settings-modal.tsx @@ -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 = { - // 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 - - {t("title")} + {t("title")} {loading ? ( -
- -
+
) : (
- {/* ── Embedding ── */} - - - - - {t("embedding.title")} - - {t("embedding.description")} - - -
- -
-

{t("embedding.importance")}

-

{t("embedding.supportedProviders")}

-
-
+ { if (selectedEmbProviderData) verifyEmbedding(selectedEmbProviderData.id, embModel.trim() || undefined, 1536); }} + verifying={embVerifying} verifyResult={embResult} + canVerify={!!selectedEmbProviderData && !!embModel.trim()} + /> - { 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")} - /> - -
- - {embResult && ( - - {embResult.valid ? ( - <> - {embResult.dimension_mismatch ? ( - - ) : ( - - )} - {embResult.dimension_mismatch - ? t("embedding.dimensionsMismatch", { count: embResult.dimensions }) - : t("embedding.dimensions", { count: embResult.dimensions })} - - ) : ( - <> - - {embResult.error || t("embedding.verifyFailed")} - - )} - - )} -
- -
-
- - setEmbMaxChunkLen(e.target.value)} className="text-base md:text-sm" /> -

{t("embedding.maxChunkLenHint")}

-
-
- - setEmbChunkOverlap(e.target.value)} className="text-base md:text-sm" /> -

{t("embedding.chunkOverlapHint")}

-
-
-
-
- - {/* ── Knowledge Graph Extraction ── */} + {/* Knowledge Graph */} - - - {t("kg.title")} - + {t("kg.title")} {t("kg.description")} - { 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")} - /> - + { 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")} />
- setKgMinConfidence(e.target.value)} - className="max-w-[120px] text-base md:text-sm" - /> + setKgMinConfidence(e.target.value)} className="max-w-[120px] text-base md:text-sm" />

{t("kg.minConfidenceHint")}

-
- - {t("kg.info")} + {t("kg.info")}
- {/* ── UX Behavior — uses FeatureSwitchGroup (same as config page) ── */} - + + - - {/* ── Pending Compaction ── */} - - - {t("compaction.title")} - {t("compaction.description")} - - -
- -
- -
-
- -
- -

{t("compaction.thresholdHint")}

-
-
- setCompThreshold(e.target.value)} - placeholder="200" min={1} - className="w-24 shrink-0 text-base md:text-sm" - /> -
- -
-
- -
- -

{t("compaction.keepRecentHint")}

-
-
- setCompKeepRecent(e.target.value)} - placeholder="40" min={1} - className="w-24 shrink-0 text-base md:text-sm" - /> -
- -
-
- -
- -

{t("compaction.maxTokensHint")}

-
-
- setCompMaxTokens(e.target.value)} - placeholder="4096" min={256} - className="w-24 shrink-0 text-base md:text-sm" - /> -
- -
- - {t("compaction.info")} -
-
-
)} @@ -510,23 +195,15 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
{embChanged && !embVerified && (

- - {t("embedding.verifyRequired")} + {t("embedding.verifyRequired")}

)}
- onOpenChange(false)} - className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" - > - - {t("moreConfig")} + onOpenChange(false)} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"> + {t("moreConfig")}
- + +
+
+        {highlighted ? (
+          
+        ) : (
+          
+            {content}
+          
+        )}
+      
+
+ ); +} + +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
{content}
; + const body = rows.slice(1); + + return ( +
+
+ {t("csvRows", { count: body.length })} + +
+
+ + + + {header.map((col, i) => ( + + ))} + + + + {body.map((row, i) => ( + + {header.map((_, j) => ( + + ))} + + ))} + +
+ {col} +
+ {row[j] ?? ""} +
+
+
+ ); +} + +export function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise }) { + const { t } = useTranslation("common"); + const [src, setSrc] = useState(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 ( +
+ +
+ ); + } + + if (error || !src) { + return ( +
+ {t("failedToLoadImage")} +
+ ); + } + + return ( +
+ {path.split("/").pop() +
+ ); +} + +export function UnsupportedFileViewer({ + path, + size, + onDownload, +}: { + path: string; + size: number; + onDownload?: () => void; +}) { + const { t } = useTranslation("storage"); + const fileName = path.split("/").pop() ?? path; + + return ( +
+ +

{t("unsupportedFile")}

+ {onDownload && ( + + )} +
+ ); +} diff --git a/ui/web/src/components/shared/file-viewers.tsx b/ui/web/src/components/shared/file-viewers.tsx index 90cd1d5b..db754a9f 100644 --- a/ui/web/src/components/shared/file-viewers.tsx +++ b/ui/web/src/components/shared/file-viewers.tsx @@ -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(null); - - const highlighted = useMemo(() => { - if (language && hljs.getLanguage(language)) { - try { - return hljs.highlight(content, { language }).value; - } catch { /* fallback */ } - } - return null; - }, [content, language]); - - return ( -
-
- {language || "text"} - -
-
-        {highlighted ? (
-          
-        ) : (
-          {content}
-        )}
-      
-
- ); -} - -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
{content}
; - const body = rows.slice(1); - - return ( -
-
- {t("csvRows", { count: body.length })} - -
-
- - - - {header.map((col, i) => ( - - ))} - - - - {body.map((row, i) => ( - - {header.map((_, j) => ( - - ))} - - ))} - -
- {col} -
- {row[j] ?? ""} -
-
-
- ); -} - -function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise }) { - const { t } = useTranslation("common"); - const [src, setSrc] = useState(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 ( -
- -
- ); - } - - if (error || !src) { - return ( -
- {t("failedToLoadImage")} -
- ); - } - - return ( -
- {path.split("/").pop() -
- ); -} - -function UnsupportedFileViewer({ - path, - size, - onDownload, -}: { - path: string; - size: number; - onDownload?: () => void; -}) { - const { t } = useTranslation("storage"); - const fileName = path.split("/").pop() ?? path; - - return ( -
- -

{t("unsupportedFile")}

- {onDownload && ( - - )} -
- ); -} +import { + CodeViewer, + CsvViewer, + ImageViewer, + UnsupportedFileViewer, +} from "./file-viewer-panels"; export function FileContentBody({ path, diff --git a/ui/web/src/components/shared/markdown-cached-img.tsx b/ui/web/src/components/shared/markdown-cached-img.tsx new file mode 100644 index 00000000..58bb1de6 --- /dev/null +++ b/ui/web/src/components/shared/markdown-cached-img.tsx @@ -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 ( + + {alt { + e.preventDefault(); + if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image"); + }} + {...props} + /> + {resolvedSrc && ( + 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" + > + + + )} + + ); +} diff --git a/ui/web/src/components/shared/markdown-code-block.tsx b/ui/web/src/components/shared/markdown-code-block.tsx new file mode 100644 index 00000000..a9557d5c --- /dev/null +++ b/ui/web/src/components/shared/markdown-code-block.tsx @@ -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 ( +
+
+ {lang || "code"} + +
+
+        
+          {children}
+        
+      
+
+ ); +} diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index af8b18c5..7ef0a8d1 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -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 ( -
-
- {lang || "code"} - -
-
-        {children}
-      
-
- ); -} +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 ( - - {alt { - e.preventDefault(); - if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image"); - }} - {...props} - /> - {resolvedSrc && ( - 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" - > - - - )} - - ); -} - export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) { const gallery = useChatImageGallery(); const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); diff --git a/ui/web/src/pages/agents/agent-detail/heartbeat-advanced-panel.tsx b/ui/web/src/pages/agents/agent-detail/heartbeat-advanced-panel.tsx new file mode 100644 index 00000000..65809391 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/heartbeat-advanced-panel.tsx @@ -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 ( +
+ + {showAdvanced && ( +
+
+
+ + setAckMaxChars(Number(e.target.value))} + className="text-base md:text-sm" + /> +

{t("heartbeat.ackMaxCharsHint")}

+
+
+ + setMaxRetries(Number(e.target.value))} + className="text-base md:text-sm" + /> +

{t("heartbeat.maxRetriesHint")}

+
+
+
+
+ {t("heartbeat.isolatedSession")} +

{t("heartbeat.isolatedSessionHint")}

+
+ +
+
+
+ {t("heartbeat.lightContext")} +

{t("heartbeat.lightContextHint")}

+
+ +
+
+ )} +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx b/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx index 1c7b6453..c12f745b 100644 --- a/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx +++ b/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx @@ -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; fetchTargets: () => Promise; refresh: () => Promise; - /** 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([]); - const [testRunning, setTestRunning] = useState(false); const showTestSpin = useMinLoading(testRunning, 600); + const providerNameById = useMemo(() => { + const map: Record = {}; + 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 = {}; - 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({ - {/* Scrollable body — standard pattern: -mx-4 px-4 */}
- {/* ── Enable + Interval (top priority) ── */} + {/* Enable + Interval */}
@@ -199,7 +186,7 @@ export function HeartbeatConfigDialog({
- {/* ── Provider / Model override ── */} + {/* Provider / Model override */}
@@ -220,188 +207,28 @@ export function HeartbeatConfigDialog({ />
- {/* ── Delivery — WHERE it sends ── */} -
-
- -

- {t("heartbeat.sectionDelivery")} -

-
-
-
- - {channelNames.length > 0 ? ( - - ) : ( - setChannel(e.target.value)} - className="text-base md:text-sm" - /> - )} -
-
- - {(() => { - if (!channel) { - return ( - - ); - } - const filtered = targets.filter((t) => t.channel === channel); - if (filtered.length > 0) { - return ( - - ); - } - return ( - setChatId(e.target.value)} - className="text-base md:text-sm" - /> - ); - })()} -
-
-
+ - {/* ── Schedule — WHEN it runs ── */} -
-
- -

- {t("heartbeat.sectionSchedule")} -

-
-
-
- - setActiveHoursStart(e.target.value)} - className="text-base md:text-sm" - /> -
-
- - setActiveHoursEnd(e.target.value)} - className="text-base md:text-sm" - /> -
-
- - setTimezone(v === "__auto__" ? "" : v)} - options={[{ value: "__auto__", label: defaultTz }, ...getAllIanaTimezones()]} - placeholder={t("heartbeat.timezone")} - className="text-base md:text-sm" - /> -
-
-

{t("heartbeat.scheduleHint")}

-
+ - {/* ── Advanced (collapsible) ── */} -
- - {showAdvanced && ( -
-
-
- - setAckMaxChars(Number(e.target.value))} - className="text-base md:text-sm" - /> -

{t("heartbeat.ackMaxCharsHint")}

-
-
- - setMaxRetries(Number(e.target.value))} - className="text-base md:text-sm" - /> -

{t("heartbeat.maxRetriesHint")}

-
-
-
-
- {t("heartbeat.isolatedSession")} -

{t("heartbeat.isolatedSessionHint")}

-
- -
-
-
- {t("heartbeat.lightContext")} -

{t("heartbeat.lightContextHint")}

-
- -
-
- )} -
+ - {/* ── Checklist — WHAT the agent does ── */} + {/* Checklist */}
@@ -426,22 +253,18 @@ export function HeartbeatConfigDialog({ )}
- {/* Bottom padding for scroll */}
- {/* Footer — fixed at bottom */} + {/* Footer */}
diff --git a/ui/web/src/pages/agents/agent-detail/heartbeat-delivery-section.tsx b/ui/web/src/pages/agents/agent-detail/heartbeat-delivery-section.tsx new file mode 100644 index 00000000..48646002 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/heartbeat-delivery-section.tsx @@ -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 ( +
+
+ +

+ {t("heartbeat.sectionDelivery")} +

+
+
+
+ + {channelNames.length > 0 ? ( + + ) : ( + setChannel(e.target.value)} + className="text-base md:text-sm" + /> + )} +
+
+ + {(() => { + if (!channel) { + return ; + } + const filtered = targets.filter((tgt) => tgt.channel === channel); + if (filtered.length > 0) { + return ( + + ); + } + return ( + setChatId(e.target.value)} + className="text-base md:text-sm" + /> + ); + })()} +
+
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/heartbeat-schedule-section.tsx b/ui/web/src/pages/agents/agent-detail/heartbeat-schedule-section.tsx new file mode 100644 index 00000000..c1363f72 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/heartbeat-schedule-section.tsx @@ -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 ( +
+
+ +

+ {t("heartbeat.sectionSchedule")} +

+
+
+
+ + setActiveHoursStart(e.target.value)} + className="text-base md:text-sm" + /> +
+
+ + setActiveHoursEnd(e.target.value)} + className="text-base md:text-sm" + /> +
+
+ + setTimezone(v === "__auto__" ? "" : v)} + options={[{ value: "__auto__", label: defaultTz }, ...getAllIanaTimezones()]} + placeholder={t("heartbeat.timezone")} + className="text-base md:text-sm" + /> +
+
+

{t("heartbeat.scheduleHint")}

+
+ ); +} diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-category-group.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-category-group.tsx new file mode 100644 index 00000000..1fc8fcca --- /dev/null +++ b/ui/web/src/pages/builtin-tools/builtin-tool-category-group.tsx @@ -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; + onDeleteTenantConfig: (name: string) => Promise; +} + +export function CategoryGroup({ + category, + tools, + onToggle, + onSettings, + tenantId, + onSetTenantConfig, + onDeleteTenantConfig, +}: CategoryGroupProps) { + const { t } = useTranslation("tools"); + return ( +
+
+ {t(`builtin.categories.${category}`, category)} + + {tools.length} + +
+
+ {tools.map((tool) => ( + + ))} +
+
+ ); +} diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-row.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-row.tsx new file mode 100644 index 00000000..6337c28d --- /dev/null +++ b/ui/web/src/pages/builtin-tools/builtin-tool-row.tsx @@ -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; + onDeleteTenantConfig: (name: string) => Promise; +} + +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 ( +
+ + + + + {overrideLabel} + + + +

{t("builtin.tenantOverride")}

+
+
+
+ onSetTenantConfig(tool.name, checked)} + className="scale-75 origin-right" + aria-label={t("builtin.tenantOverride")} + /> + {hasOverride && ( + + + + + + +

{t("builtin.resetToDefault")}

+
+
+
+ )} +
+ ); +} + +interface ToolRowProps { + tool: BuiltinToolData; + onToggle: (tool: BuiltinToolData) => void; + onSettings: (tool: BuiltinToolData) => void; + tenantId: string | null; + onSetTenantConfig: (name: string, enabled: boolean) => Promise; + onDeleteTenantConfig: (name: string) => Promise; +} + +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 ( +
+
+
+ {tool.display_name} + {tool.name} + {deprecated && ( + + + + + {t("builtin.deprecated")} + + + +

{t("builtin.deprecatedTooltip")}

+
+
+
+ )} + {!deprecated && tool.requires && tool.requires.length > 0 && ( + + + + + {t("builtin.requires")} + + + +

{t("builtin.requiresTooltip", { list: tool.requires.join(", ") })}

+
+
+
+ )} +
+ {tool.description && ( +

+ {t(`builtin.descriptions.${tool.name}`, tool.description)} +

+ )} +
+ +
+ {editable && !deprecated && ( + + )} + {!editable && !deprecated && configHint && ( + + + + + + {configHint} + + + +

{t("builtin.configuredVia")}

+
+
+
+ )} + {hasTenantScope && !deprecated ? ( + + ) : ( + onToggle(tool)} + disabled={deprecated} + /> + )} +
+
+ ); +} diff --git a/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx b/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx index 73374585..39055458 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx @@ -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; - onDeleteTenantConfig: (name: string) => Promise; -}) { - const { t } = useTranslation("tools"); - return ( -
-
- {t(`builtin.categories.${category}`, category)} - - {tools.length} - -
-
- {tools.map((tool) => ( - - ))} -
-
- ); -} - -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; - onDeleteTenantConfig: (name: string) => Promise; -}) { - 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 ( -
-
-
- {tool.display_name} - {tool.name} - {deprecated && ( - - - - - {t("builtin.deprecated")} - - - -

{t("builtin.deprecatedTooltip")}

-
-
-
- )} - {!deprecated && tool.requires && tool.requires.length > 0 && ( - - - - - {t("builtin.requires")} - - - -

{t("builtin.requiresTooltip", { list: tool.requires.join(", ") })}

-
-
-
- )} -
- {tool.description && ( -

- {t(`builtin.descriptions.${tool.name}`, tool.description)} -

- )} -
- -
- {editable && !deprecated && ( - - )} - {!editable && !deprecated && configHint && ( - - - - - - {configHint} - - - -

{t("builtin.configuredVia")}

-
-
-
- )} - {hasTenantScope && !deprecated ? ( - - ) : ( - onToggle(tool)} - disabled={deprecated} - /> - )} -
-
- ); -} - -function TenantOverrideControl({ - tool, - hasOverride, - onSetTenantConfig, - onDeleteTenantConfig, -}: { - tool: BuiltinToolData; - hasOverride: boolean; - onSetTenantConfig: (name: string, enabled: boolean) => Promise; - onDeleteTenantConfig: (name: string) => Promise; -}) { - 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 ( -
- - - - - {overrideLabel} - - - -

{t("builtin.tenantOverride")}

-
-
-
- onSetTenantConfig(tool.name, checked)} - className="scale-75 origin-right" - aria-label={t("builtin.tenantOverride")} - /> - {hasOverride && ( - - - - - - -

{t("builtin.resetToDefault")}

-
-
-
- )} -
- ); -} diff --git a/ui/web/src/pages/builtin-tools/media-param-field-control.tsx b/ui/web/src/pages/builtin-tools/media-param-field-control.tsx new file mode 100644 index 00000000..82e72481 --- /dev/null +++ b/ui/web/src/pages/builtin-tools/media-param-field-control.tsx @@ -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 ( +
+ + {field.type === "select" && field.options && ( + + )} + {field.type === "toggle" && ( +
+ +
+ )} + {field.type === "number" && ( + onChange(Number(e.target.value))} + className="h-8 text-sm" + /> + )} + {field.type === "text" && ( +
+ onChange(e.target.value)} + placeholder={field.description} + className="h-8 text-sm" + /> + {field.description && ( +

{field.description}

+ )} +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx b/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx index 956b52c5..5186d807 100644 --- a/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx +++ b/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx @@ -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; -} +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 { - const schema = MEDIA_PARAMS_SCHEMA[toolName]?.[providerType] ?? []; - const defaults: Record = {}; - for (const field of schema) { - if (field.default !== undefined) { - defaults[field.key] = field.default; - } - } - return defaults; -} - -function parseInitialEntries( - settings: Record, - providers: ReturnType["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[]) - .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) ?? {}, - }; - }) - .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["providers"]; - onUpdate: (id: string, patch: Partial) => void; - onRemove: (id: string) => void; - portalRef: React.RefObject; -} - -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 ( -
- {/* Row 1: drag handle, index, toggle, delete */} -
- - - - #{index + 1} - - - onUpdate(entry.id, { enabled: v })} - /> - - - {selectedProvider?.display_name || entry.provider || t("builtin.mediaChain.newProvider")} - - - -
- - {/* Row 2: provider + model selects */} -
-
- - -
-
- - 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} - /> -
-
- - {/* Row 3: timeout, retries, expand button */} -
-
- -
- onUpdate(entry.id, { timeout: Number(e.target.value) })} - className="h-7 w-20 text-sm pr-5" - /> - s -
-
-
- - onUpdate(entry.id, { max_retries: Number(e.target.value) })} - className="h-7 w-16 text-sm" - /> -
- - {paramSchema.length > 0 && ( - - )} -
- - {/* Collapsible params */} - {expanded && paramSchema.length > 0 && ( -
- {paramSchema.map((field) => ( - handleParamChange(field.key, v)} - /> - ))} -
- )} -
- ); -} - -// Renders a single param field based on its type -function ParamFieldControl({ - field, - value, - onChange, -}: { - field: ParamField; - value: unknown; - onChange: (v: unknown) => void; -}) { - return ( -
- - {field.type === "select" && field.options && ( - - )} - {field.type === "toggle" && ( -
- -
- )} - {field.type === "number" && ( - onChange(Number(e.target.value))} - className="h-8 text-sm" - /> - )} - {field.type === "text" && ( -
- onChange(e.target.value)} - placeholder={field.description} - className="h-8 text-sm" - /> - {field.description && ( -

{field.description}

- )} -
- )} -
- ); -} - -// Main form component +/** Dialog form for configuring an ordered provider chain for a media tool. */ export function MediaProviderChainForm({ toolName, initialSettings, diff --git a/ui/web/src/pages/builtin-tools/media-provider-chain-helpers.ts b/ui/web/src/pages/builtin-tools/media-provider-chain-helpers.ts new file mode 100644 index 00000000..59d7d1f2 --- /dev/null +++ b/ui/web/src/pages/builtin-tools/media-provider-chain-helpers.ts @@ -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; +} + +/** 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 { + const schema = MEDIA_PARAMS_SCHEMA[toolName]?.[providerType] ?? []; + const defaults: Record = {}; + 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, + providers: Array<{ id: string; name: string }>, +): ProviderEntry[] { + // New format: { providers: [...] } + if (Array.isArray(settings.providers)) { + return (settings.providers as Record[]) + .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) ?? {}, + }; + }) + .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 []; +} diff --git a/ui/web/src/pages/builtin-tools/media-sortable-provider-card.tsx b/ui/web/src/pages/builtin-tools/media-sortable-provider-card.tsx new file mode 100644 index 00000000..b24c9398 --- /dev/null +++ b/ui/web/src/pages/builtin-tools/media-sortable-provider-card.tsx @@ -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["providers"]; + onUpdate: (id: string, patch: Partial) => void; + onRemove: (id: string) => void; + portalRef: React.RefObject; +} + +/** 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 ( +
+ {/* Row 1: drag handle, index, toggle, delete */} +
+ + + + #{index + 1} + + + onUpdate(entry.id, { enabled: v })} + /> + + + {selectedProvider?.display_name || entry.provider || t("builtin.mediaChain.newProvider")} + + + +
+ + {/* Row 2: provider + model selects */} +
+
+ + +
+
+ + 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} + /> +
+
+ + {/* Row 3: timeout, retries, expand button */} +
+
+ +
+ onUpdate(entry.id, { timeout: Number(e.target.value) })} + className="h-7 w-20 text-sm pr-5" + /> + s +
+
+
+ + onUpdate(entry.id, { max_retries: Number(e.target.value) })} + className="h-7 w-16 text-sm" + /> +
+ + {paramSchema.length > 0 && ( + + )} +
+ + {/* Collapsible params */} + {expanded && paramSchema.length > 0 && ( +
+ {paramSchema.map((field) => ( + handleParamChange(field.key, v)} + /> + ))} +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/channels/channel-detail/channel-manager-inline-add-form.tsx b/ui/web/src/pages/channels/channel-detail/channel-manager-inline-add-form.tsx new file mode 100644 index 00000000..57ecbf1d --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-manager-inline-add-form.tsx @@ -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; +} + +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 ( +
+ {t("detail.managers.addForm.title")} +

{t("detail.managers.addForm.hint")}

+
+
+ + setFormGroupId(e.target.value)} + placeholder={t("detail.managers.addForm.groupIdPlaceholder")} + className="text-base md:text-sm" + /> +
+
+ + +
+ +
+ {error &&

{error}

} +
+ ); + } + + return ( +
+
+ + +
+ + {error &&

{error}

} +
+ ); +} diff --git a/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx index 89e75cf9..378908d7 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx @@ -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; @@ -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; -} - -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 ( -
- {t("detail.managers.addForm.title")} -

{t("detail.managers.addForm.hint")}

-
-
- - setFormGroupId(e.target.value)} - placeholder={t("detail.managers.addForm.groupIdPlaceholder")} - className="text-base md:text-sm" - /> -
-
- - -
- -
- {error &&

{error}

} -
- ); - } - - return ( -
-
- - -
- - {error &&

{error}

} -
- ); -} - -// --- Main tab component --- - export function ChannelManagersTab({ listManagerGroups, listManagers, diff --git a/ui/web/src/pages/chat/hooks/chat-message-transformer.ts b/ui/web/src/pages/chat/hooks/chat-message-transformer.ts new file mode 100644 index 00000000..164457d5 --- /dev/null +++ b/ui/web/src/pages/chat/hooks/chat-message-transformer.ts @@ -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(); + 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; +} diff --git a/ui/web/src/pages/chat/hooks/use-chat-messages.ts b/ui/web/src/pages/chat/hooks/use-chat-messages.ts index eaab7e10..90ae4089 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -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(null); const [blockReplies, setBlockReplies] = useState([]); - const [teamTasks, setTeamTasks] = useState([]); - // 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(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(); - 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, }; } diff --git a/ui/web/src/pages/chat/hooks/use-chat-team-tasks.ts b/ui/web/src/pages/chat/hooks/use-chat-team-tasks.ts new file mode 100644 index 00000000..8e5cbceb --- /dev/null +++ b/ui/web/src/pages/chat/hooks/use-chat-team-tasks.ts @@ -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([]); + + // 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 }; +} diff --git a/ui/web/src/pages/cli-credentials/cli-credential-env-vars-section.tsx b/ui/web/src/pages/cli-credentials/cli-credential-env-vars-section.tsx new file mode 100644 index 00000000..89fa83ab --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-credential-env-vars-section.tsx @@ -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; + setEnvValues: (updater: (prev: Record) => Record) => 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 ( +
+

{t("form.envVars")}

+ {activePreset.env_vars.map((ev) => ( +
+ + setEnvValues((prev) => ({ ...prev, [ev.name]: e.target.value }))} + className="text-base md:text-sm" + /> + {ev.desc &&

{ev.desc}

} +
+ ))} +
+ ); + } + + // Manual free-form key/value entries + if (isManualMode) { + return ( +
+
+

{t("form.envVars")}

+ +
+ {manualEnvEntries.length === 0 && ( +

{t("form.noEnvVarsHint")}

+ )} + {manualEnvEntries.map((entry, idx) => ( +
+
+ updateEntry(idx, "key", e.target.value)} + className="text-base md:text-sm font-mono" + /> +
+
+ updateEntry(idx, "value", e.target.value)} + className="text-base md:text-sm" + /> +
+ +
+ ))} +
+ ); + } + + return null; +} diff --git a/ui/web/src/pages/cli-credentials/cli-credential-form-dialog.tsx b/ui/web/src/pages/cli-credentials/cli-credential-form-dialog.tsx index 1de83fba..165c6274 100644 --- a/ui/web/src/pages/cli-credentials/cli-credential-form-dialog.tsx +++ b/ui/web/src/pages/cli-credentials/cli-credential-form-dialog.tsx @@ -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>({}); const [manualEnvEntries, setManualEnvEntries] = useState([]); - /** Snapshot when dialog opens (edit) — detect clearing all env rows */ const [initialEnvKeys, setInitialEnvKeys] = useState([]); 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 | null => { if (!isManualMode) return envValues; const env: Record = {}; @@ -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 ))} -

- {t("form.presetHint")} -

+

{t("form.presetHint")}

)} - {/* Existing credentials indicator (edit mode) */} {isEdit && (

{t("form.encryptedHint")}

)} - {/* Env var inputs from preset */} - {activePreset && activePreset.env_vars.length > 0 && ( -
-

{t("form.envVars")}

- {activePreset.env_vars.map((ev) => ( -
- - setEnvValues((prev) => ({ ...prev, [ev.name]: e.target.value }))} - className="text-base md:text-sm" - /> - {ev.desc && ( -

{ev.desc}

- )} -
- ))} -
- )} - - {/* Manual env var entries (no preset selected) */} - {isManualMode && ( -
-
-

{t("form.envVars")}

- -
- {manualEnvEntries.length === 0 && ( -

{t("form.noEnvVarsHint")}

- )} - {manualEnvEntries.map((entry, idx) => ( -
-
- updateManualEnvEntry(idx, "key", e.target.value)} - className="text-base md:text-sm font-mono" - /> -
-
- updateManualEnvEntry(idx, "value", e.target.value)} - className="text-base md:text-sm" - /> -
- -
- ))} -
- )} + {/* Binary name + check button */}
@@ -360,13 +262,17 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset {checkResult && (

{checkResult.found ? : } - {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"))}

)} {checking &&

{t("form.checking")}

}
- +
- +
- +
- {/* Agent selector */}
- + onChange(e.target.value)} + placeholder={placeholder} + /> +
+ ); +} diff --git a/ui/web/src/pages/config/sections/ai-defaults-section.tsx b/ui/web/src/pages/config/sections/ai-defaults-section.tsx index 2620ecf6..0fa09830 100644 --- a/ui/web/src/pages/config/sections/ai-defaults-section.tsx +++ b/ui/web/src/pages/config/sections/ai-defaults-section.tsx @@ -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; @@ -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 ( -
- - {open &&
{children}
} -
- ); -} - -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 ( -
- {tip ? {label} : } - onChange(e.target.value)} - placeholder={placeholder} - /> -
- ); -} diff --git a/ui/web/src/pages/config/sections/quota-overrides-table.tsx b/ui/web/src/pages/config/sections/quota-overrides-table.tsx new file mode 100644 index 00000000..d7cf17a2 --- /dev/null +++ b/ui/web/src/pages/config/sections/quota-overrides-table.tsx @@ -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 ( +
+
+ {t("quota.hour")} + onChange({ ...value, hour: Number(e.target.value) })} + /> +
+
+ {t("quota.day")} + onChange({ ...value, day: Number(e.target.value) })} + /> +
+
+ {t("quota.week")} + onChange({ ...value, week: Number(e.target.value) })} + /> +
+
+ ); +} + +interface OverridesTableProps { + label: string; + tip: string; + entries: Record; + onChange: (v: Record) => 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 = {}; + 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 ( +
+ {label} + {keys.map((key, i) => ( +
+
+
+ {i === 0 && ( + {t("quota.keyLabel")} + )} + {options ? ( + + ) : ( + updateKey(key, e.target.value)} + /> + )} +
+
+ {i === 0 && ( + {t("quota.hour")} + )} + updateWindow(key, { ...entries[key], hour: Number(e.target.value) })} + /> +
+
+ {i === 0 && ( + {t("quota.day")} + )} + updateWindow(key, { ...entries[key], day: Number(e.target.value) })} + /> +
+
+ {i === 0 && ( + {t("quota.week")} + )} + updateWindow(key, { ...entries[key], week: Number(e.target.value) })} + /> +
+ +
+
+ ))} + {options ? ( + + ) : ( + + )} +
+ ); +} diff --git a/ui/web/src/pages/config/sections/quota-section.tsx b/ui/web/src/pages/config/sections/quota-section.tsx index dd54c944..34bdd485 100644 --- a/ui/web/src/pages/config/sections/quota-section.tsx +++ b/ui/web/src/pages/config/sections/quota-section.tsx @@ -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 ( -
-
- {t("quota.hour")} - onChange({ ...value, hour: Number(e.target.value) })} - /> -
-
- {t("quota.day")} - onChange({ ...value, day: Number(e.target.value) })} - /> -
-
- {t("quota.week")} - onChange({ ...value, week: Number(e.target.value) })} - /> -
-
- ); -} - -function OverridesTable({ - label, - tip, - entries, - onChange, - keyPlaceholder, - options, -}: { - label: string; - tip: string; - entries: Record; - onChange: (v: Record) => 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 = {}; - 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 ( -
- {label} - {keys.map((key, i) => ( -
-
-
- {i === 0 && ( - {t("quota.keyLabel")} - )} - {options ? ( - - ) : ( - updateKey(key, e.target.value)} - /> - )} -
-
- {i === 0 && ( - {t("quota.hour")} - )} - - updateWindow(key, { - ...entries[key], - hour: Number(e.target.value), - }) - } - /> -
-
- {i === 0 && ( - {t("quota.day")} - )} - - updateWindow(key, { - ...entries[key], - day: Number(e.target.value), - }) - } - /> -
-
- {i === 0 && ( - {t("quota.week")} - )} - - updateWindow(key, { - ...entries[key], - week: Number(e.target.value), - }) - } - /> -
- -
-
- ))} - {options ? ( - - ) : ( - - )} -
- ); -} export function QuotaSection({ data, onSave, saving }: Props) { const { t } = useTranslation("config"); diff --git a/ui/web/src/pages/mcp/mcp-grants-dialog.tsx b/ui/web/src/pages/mcp/mcp-grants-dialog.tsx index d3b357f0..62828553 100644 --- a/ui/web/src/pages/mcp/mcp-grants-dialog.tsx +++ b/ui/web/src/pages/mcp/mcp-grants-dialog.tsx @@ -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(); - 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({
- {/* Existing grants */} + {/* Existing grants list */} {grants.length > 0 && (
@@ -195,16 +182,16 @@ export function MCPGrantsDialog({ {hasAllow && (
allow - {grant.tool_allow!.map((t) => ( - {t} + {grant.tool_allow!.map((tool) => ( + {tool} ))}
)} {hasDeny && (
deny - {grant.tool_deny!.map((t) => ( - {t} + {grant.tool_deny!.map((tool) => ( + {tool} ))}
)} @@ -288,157 +275,13 @@ export function MCPGrantsDialog({ )}
+ {error &&

{error}

}
- {/* Portal target for dropdowns — inside dialog (pointer events), outside overflow (no clipping) */} + + {/* Portal target for dropdowns — inside dialog, outside overflow clipping */}
); } - -// 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; -}) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const containerRef = useRef(null); - const inputRef = useRef(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) => { - 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({}); - 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 ( -
-
inputRef.current?.focus()} - > - {value.map((name) => ( - - {name} - - - ))} - { - 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" - /> - setOpen(!open)} - /> -
- {open && filtered.length > 0 && portalContainer?.current && createPortal( -
- {filtered.map((t) => ( - - ))} -
, - portalContainer.current, - )} -
- ); -} diff --git a/ui/web/src/pages/mcp/mcp-tool-multi-select.tsx b/ui/web/src/pages/mcp/mcp-tool-multi-select.tsx new file mode 100644 index 00000000..03dcaa8a --- /dev/null +++ b/ui/web/src/pages/mcp/mcp-tool-multi-select.tsx @@ -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; +} + +/** 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(null); + const inputRef = useRef(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) => { + 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({}); + 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 ( +
+
inputRef.current?.focus()} + > + {value.map((name) => ( + + {name} + + + ))} + { 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" + /> + setOpen(!open)} + /> +
+ {open && filtered.length > 0 && portalContainer?.current && createPortal( +
+ {filtered.map((t) => ( + + ))} +
, + portalContainer.current, + )} +
+ ); +} diff --git a/ui/web/src/pages/providers/hooks/use-provider-verify.ts b/ui/web/src/pages/providers/hooks/use-provider-verify.ts index 076e3a0a..ca726bfa 100644 --- a/ui/web/src/pages/providers/hooks/use-provider-verify.ts +++ b/ui/web/src/pages/providers/hooks/use-provider-verify.ts @@ -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; diff --git a/ui/web/src/pages/providers/provider-detail/provider-embedding-section.tsx b/ui/web/src/pages/providers/provider-detail/provider-embedding-section.tsx new file mode 100644 index 00000000..39e6b1de --- /dev/null +++ b/ui/web/src/pages/providers/provider-detail/provider-embedding-section.tsx @@ -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 ( +
+

{t("detail.embeddingSection")}

+
+
+ +

{t("embedding.enableDesc")}

+
+ +
+ + {embEnabled ? ( +
+
+ + setEmbModel(e.target.value)} + placeholder="text-embedding-3-small" + className="text-base md:text-sm" + /> +
+ +
+ +

1536

+

{t("embedding.dimensionsHint")}

+
+ +
+ + setEmbApiBase(e.target.value)} + placeholder={t("embedding.apiBasePlaceholder")} + className="text-base md:text-sm" + /> +

{t("embedding.apiBaseHint")}

+
+ +
+ + {verifyResult ? ( + + {verifyResult.valid ? ( + <> + {verifyResult.dimension_mismatch ? ( + + ) : ( + + )} + {verifyResult.dimension_mismatch + ? t("embedding.dimensionsMismatch", { count: verifyResult.dimensions }) + : `${verifyResult.dimensions} dimensions`} + + ) : ( + <> + + {verifyResult.error || t("embedding.verifyFailed")} + + )} + + ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts b/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts new file mode 100644 index 00000000..4e221705 --- /dev/null +++ b/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts @@ -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, + 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), + }); +} diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx index e950db73..79bce182 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx @@ -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; } -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, - 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(); const ownerByMember = new Map(); @@ -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(() => { 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) }; 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 (
+ {/* Identity section */}

{t("detail.identity")}

-
- setDisplayName(e.target.value)} - placeholder={isOAuth ? t("form.oauthDisplayNamePlaceholder") : t("form.displayNamePlaceholder")} - className="text-base md:text-sm" - /> - {isOAuth ? ( -

{t("form.oauthDisplayNameHint")}

- ) : null} + setDisplayName(e.target.value)} placeholder={isOAuth ? t("form.oauthDisplayNamePlaceholder") : t("form.displayNamePlaceholder")} className="text-base md:text-sm" /> + {isOAuth ?

{t("form.oauthDisplayNameHint")}

: null}
-
-
- {typeLabel} -
+
{typeLabel}
-
- - {provider.name} - -
- {isOAuth ? ( - - ) : null} + {isOAuth ? : null} {showReasoningDefaults ? ( -
-
-

{t("detail.reasoningDefaultsTitle")}

-

- {t("detail.reasoningDefaultsDescription")} -

-
- -
- - -
- -
-
-
-

{t("detail.reasoningExpertMode")}

-

- {t("detail.reasoningExpertModeDesc")} -

-
- { - setReasoningExpert(enabled); - if (!enabled) { - const legacy = deriveLegacyThinkingLevel(reasoningEffort); - setReasoningThinkingLevel(legacy); - setReasoningFallback("downgrade"); - } else if (reasoningEffort === "off" && reasoningThinkingLevel !== "off") { - setReasoningEffort(reasoningThinkingLevel); - } - }} - /> -
- -
- {reasoningPreviewEntry ? ( - <> -

- {t("detail.reasoningPreviewDescription", { - model: reasoningPreviewEntry.name || reasoningPreviewEntry.id, - })} -

-
- - -
- {reasoningPreviewCapability?.levels?.length ? ( -
- {reasoningPreviewCapability.levels.map((level) => ( - - {t(`reasoning.${level}`)} - - ))} -
- ) : null} - {reasoningPreviewCapability?.default_effort ? ( -

- {t("detail.reasoningPreviewDefault", { - level: t(`reasoning.${reasoningPreviewCapability.default_effort}`), - })} -

- ) : null} - - ) : ( -

{t("detail.reasoningPreviewEmpty")}

- )} -
- - {reasoningExpert ? ( -
-
- - -
- -
- - -
-
- ) : null} -
-
+ setReasoningFallback(v as typeof reasoningFallback)} + reasoningExpert={reasoningExpert} setReasoningExpert={setReasoningExpert} + setReasoningPreviewModel={setReasoningPreviewModel} + reasoningCapableModels={reasoningCapableModels} reasoningPreviewEntry={reasoningPreviewEntry} reasoningPreviewCapability={reasoningPreviewCapability} + /> ) : null} {canEditPoolRouting ? ( - + ) : null} {isPoolOwner ? ( - void refreshPoolActivity()} - providerByName={providerByName} - statusByName={statusByName} - quotaByName={quotaByName} - /> + void refreshPoolActivity()} providerByName={providerByName} statusByName={statusByName} quotaByName={quotaByName} /> ) : null} {showApiKey ? ( @@ -748,127 +263,28 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)

{t("detail.apiKeySection")}

- setApiKey(e.target.value)} - placeholder={t("form.apiKeyEditPlaceholder")} - className="text-base md:text-sm" - /> + setApiKey(e.target.value)} placeholder={t("form.apiKeyEditPlaceholder")} className="text-base md:text-sm" />

{t("form.apiKeySetHint")}

) : null} {showEmbedding ? ( -
-

{t("detail.embeddingSection")}

-
-
- -

{t("embedding.enableDesc")}

-
- -
- - {embEnabled ? ( -
-
- - setEmbModel(e.target.value)} - placeholder="text-embedding-3-small" - className="text-base md:text-sm" - /> -
- -
- -

1536

-

{t("embedding.dimensionsHint")}

-
- -
- - setEmbApiBase(e.target.value)} - placeholder={t("embedding.apiBasePlaceholder")} - className="text-base md:text-sm" - /> -

{t("embedding.apiBaseHint")}

-
- -
- - {embResult ? ( - - {embResult.valid ? ( - <> - {embResult.dimension_mismatch ? ( - - ) : ( - - )} - {embResult.dimension_mismatch - ? t("embedding.dimensionsMismatch", { count: embResult.dimensions }) - : `${embResult.dimensions} dimensions`} - - ) : ( - <> - - {embResult.error || t("embedding.verifyFailed")} - - )} - - ) : null} -
-
- ) : null} -
+ verifyEmbedding(provider.id, embModel.trim() || undefined, undefined)} verifying={embVerifying} verifyResult={embResult} /> ) : null}

{t("detail.statusSection")}

- +

{t("detail.enabledDesc")}

- +
); } diff --git a/ui/web/src/pages/providers/provider-detail/provider-reasoning-section.tsx b/ui/web/src/pages/providers/provider-detail/provider-reasoning-section.tsx new file mode 100644 index 00000000..bb052742 --- /dev/null +++ b/ui/web/src/pages/providers/provider-detail/provider-reasoning-section.tsx @@ -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 ( +
+
+

{t("detail.reasoningDefaultsTitle")}

+

+ {t("detail.reasoningDefaultsDescription")} +

+
+ +
+ + +
+ +
+
+
+

{t("detail.reasoningExpertMode")}

+

+ {t("detail.reasoningExpertModeDesc")} +

+
+ { + setReasoningExpert(enabled); + if (!enabled) { + const legacy = deriveLegacyThinkingLevel(reasoningEffort); + setReasoningThinkingLevel(legacy); + setReasoningFallback("downgrade"); + } else if (reasoningEffort === "off" && reasoningThinkingLevel !== "off") { + setReasoningEffort(reasoningThinkingLevel); + } + }} + /> +
+ +
+ {reasoningPreviewEntry ? ( + <> +

+ {t("detail.reasoningPreviewDescription", { + model: reasoningPreviewEntry.name || reasoningPreviewEntry.id, + })} +

+
+ + +
+ {reasoningPreviewCapability?.levels?.length ? ( +
+ {reasoningPreviewCapability.levels.map((level) => ( + + {t(`reasoning.${level}`)} + + ))} +
+ ) : null} + {reasoningPreviewCapability?.default_effort ? ( +

+ {t("detail.reasoningPreviewDefault", { + level: t(`reasoning.${reasoningPreviewCapability.default_effort}`), + })} +

+ ) : null} + + ) : ( +

{t("detail.reasoningPreviewEmpty")}

+ )} +
+ + {reasoningExpert ? ( +
+
+ + +
+ +
+ + +
+
+ ) : null} +
+
+ ); +} diff --git a/ui/web/src/pages/sessions/session-detail-page.tsx b/ui/web/src/pages/sessions/session-detail-page.tsx index 3bc38032..e0120a79 100644 --- a/ui/web/src/pages/sessions/session-detail-page.tsx +++ b/ui/web/src/pages/sessions/session-detail-page.tsx @@ -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 ( -
- -
- {title || t("detail.systemMessage")} - {body && ( - <> - - {expanded && ( -
- -
- )} - - )} -
-
- ); -} - -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(null); - - useLayoutEffect(() => { - if (contentRef.current) { - setNeedsTruncation(contentRef.current.scrollHeight > SUMMARY_MAX_HEIGHT); - } - }, [text]); - - return ( -
- {t("detail.summary")}: -
- {text} -
- {needsTruncation && ( - - )} -
- ); -} diff --git a/ui/web/src/pages/sessions/session-message-blocks.tsx b/ui/web/src/pages/sessions/session-message-blocks.tsx new file mode 100644 index 00000000..3735314b --- /dev/null +++ b/ui/web/src/pages/sessions/session-message-blocks.tsx @@ -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 ( +
+ +
+ {title || t("detail.systemMessage")} + {body && ( + <> + + {expanded && ( +
+ +
+ )} + + )} +
+
+ ); +} + +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(null); + + useLayoutEffect(() => { + if (contentRef.current) { + setNeedsTruncation(contentRef.current.scrollHeight > SUMMARY_MAX_HEIGHT); + } + }, [text]); + + return ( +
+ {t("detail.summary")}: +
+ {text} +
+ {needsTruncation && ( + + )} +
+ ); +} diff --git a/ui/web/src/pages/skills/skill-table-row.tsx b/ui/web/src/pages/skills/skill-table-row.tsx new file mode 100644 index 00000000..ecaade8e --- /dev/null +++ b/ui/web/src/pages/skills/skill-table-row.tsx @@ -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 = { + 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; + onDeleteTenantConfig: (id: string) => Promise; +} + +/** 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 ( + + +
+ + + {skill.is_system && ( + + {t("system")} + + )} + {skill.version && v{skill.version}} +
+ + + {skill.description || t("noDescription")} + + {tab === "custom" && ( + {skill.author || "—"} + )} + +
+ + {isArchived ? t("deps.statusArchived") : t("deps.statusActive")} + + {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 ( + + {shown.join(", ")}{rest > 0 && `, +${rest}`} + + ); + })()} +
+ + {tab === "custom" && ( + + {skill.visibility && ( + skill.id ? ( + + ) : ( + + {skill.visibility} + + ) + )} + + )} + +
+ {skill.id && ( + <> + {hasTenantScope ? ( + + ) : ( + onToggle(skill, checked)} + title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")} + /> + )} + + {!skill.is_system && ( + + )} + + )} +
+ + + ); +} diff --git a/ui/web/src/pages/skills/skill-tenant-override.tsx b/ui/web/src/pages/skills/skill-tenant-override.tsx new file mode 100644 index 00000000..8df18ce3 --- /dev/null +++ b/ui/web/src/pages/skills/skill-tenant-override.tsx @@ -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; + onDeleteTenantConfig: (id: string) => Promise; +} + +/** 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 ( +
+ + + + + {label} + + + +

{t("tenant.overrideHint")}

+
+
+
+ onSetTenantConfig(skill.id!, val)} + aria-label={t("tenant.overrideHint")} + /> + {hasOverride && ( + + + + + + +

{t("tenant.resetDefault")}

+
+
+
+ )} +
+ ); +} diff --git a/ui/web/src/pages/skills/skills-page.tsx b/ui/web/src/pages/skills/skills-page.tsx index 193ddfde..41d83c3f 100644 --- a/ui/web/src/pages/skills/skills-page.tsx +++ b/ui/web/src/pages/skills/skills-page.tsx @@ -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 = { - 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 */}
- - + {(["core", "custom"] as Tab[]).map((tabKey) => ( + + ))}
- - - + +
@@ -207,138 +168,22 @@ export function SkillsPage() { - {pageItems.map((skill: SkillInfo) => { - const isArchived = skill.status === "archived"; - const isDisabled = skill.enabled === false; - const hasMissing = (skill.missing_deps?.length ?? 0) > 0; - return ( - - -
- - - {skill.is_system && ( - - {t("system")} - - )} - {skill.version ? ( - v{skill.version} - ) : null} -
- - - {skill.description || t("noDescription")} - - {tab === "custom" && ( - - {skill.author || "—"} - - )} - -
- - {isArchived ? t("deps.statusArchived") : t("deps.statusActive")} - - {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 ( - - {shown.join(", ")}{rest > 0 && `, +${rest}`} - - ); - })()} -
- - {tab === "custom" && - {skill.visibility && ( - skill.id ? ( - - ) : ( - - {skill.visibility} - - ) - )} - } - -
- {skill.id && ( - <> - {hasTenantScope ? ( - { - setToggling(id); - try { await setTenantConfig(id, enabled); } finally { setToggling(null); } - }} - onDeleteTenantConfig={async (id) => { - setToggling(id); - try { await deleteTenantConfig(id); } finally { setToggling(null); } - }} - /> - ) : ( - handleToggle(skill, checked)} - title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")} - /> - )} - - {!skill.is_system && ( - - )} - - )} -
- - - ); - })} + {pageItems.map((skill: SkillInfo) => ( + + ))} setEditTarget(null)} - onSave={async (id, updates) => { - await updateSkill(id, updates); - setEditTarget(null); - }} + onSave={async (id, updates) => { await updateSkill(id, updates); setEditTarget(null); }} /> )} - + uploadSkill(f)} /> ); } - -function SkillTenantOverride({ - skill, - toggling, - onSetTenantConfig, - onDeleteTenantConfig, -}: { - skill: SkillInfo; - toggling: boolean; - onSetTenantConfig: (id: string, enabled: boolean) => Promise; - onDeleteTenantConfig: (id: string) => Promise; -}) { - 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 ( -
- - - - - {label} - - - -

{t("tenant.overrideHint")}

-
-
-
- onSetTenantConfig(skill.id!, val)} - aria-label={t("tenant.overrideHint")} - /> - {hasOverride && ( - - - - - - -

{t("tenant.resetDefault")}

-
-
-
- )} -
- ); -} diff --git a/ui/web/src/pages/teams/team-access-control-section.tsx b/ui/web/src/pages/teams/team-access-control-section.tsx new file mode 100644 index 00000000..e5061bd4 --- /dev/null +++ b/ui/web/src/pages/teams/team-access-control-section.tsx @@ -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 */} +
+

{t("settings.userAccessControl")}

+
+
+ +

{t("settings.allowedUsersHint")}

+ +
+
+ +

{t("settings.deniedUsersHint")}

+ +
+
+
+ + {/* Channel Restrictions */} +
+

{t("settings.channelRestrictions")}

+
+
+ +

{t("settings.allowedChannelsHint")}

+ +
+
+ +

{t("settings.deniedChannelsHint")}

+ +
+
+
+ + ); +} diff --git a/ui/web/src/pages/teams/team-multi-select.tsx b/ui/web/src/pages/teams/team-multi-select.tsx new file mode 100644 index 00000000..5fd4b6d7 --- /dev/null +++ b/ui/web/src/pages/teams/team-multi-select.tsx @@ -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 ( +
+ { + if (val && !selected.includes(val)) { + onChange([...selected, val]); + } + }} + options={options.filter((o) => !selected.includes(o.value))} + placeholder={placeholder} + /> + {selected.length > 0 && ( +
+ {selected.map((id) => ( + + {options.find((o) => o.value === id)?.label ?? id} + + + ))} +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/teams/team-notifications-section.tsx b/ui/web/src/pages/teams/team-notifications-section.tsx new file mode 100644 index 00000000..0ddbf741 --- /dev/null +++ b/ui/web/src/pages/teams/team-notifications-section.tsx @@ -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 ( +
+

{t("settings.notifications")}

+
+
+
+ +
+
+ {[ + { 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 }) => ( +
+
+ {label} + {hint &&

{hint}

} +
+ +
+ ))} + +
+ {t("settings.notifyMode")} +
+ {modeOptions.map((opt) => ( + + ))} +
+ {notifyMode === "leader" && ( +

+ ⚠️ {t("settings.notifyModeLeaderWarning")} +

+ )} +
+
+
+
+
+ ); +} diff --git a/ui/web/src/pages/teams/team-orchestration-section.tsx b/ui/web/src/pages/teams/team-orchestration-section.tsx new file mode 100644 index 00000000..a1a01960 --- /dev/null +++ b/ui/web/src/pages/teams/team-orchestration-section.tsx @@ -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 */} +
+

{t("settings.workspace")}

+
+
+
+ +
+
+
+ {t("settings.workspaceScope")} +

{t("settings.workspaceScopeHint")}

+
+
+ {WORKSPACE_OPTIONS.map((opt) => ( + + ))} +
+
+
+
+
+ + {/* Member Requests */} +
+

{t("settings.memberRequests")}

+
+
+
+ {t("settings.memberRequestsEnabled")} +

{t("settings.memberRequestsEnabledDesc")}

+
+ +
+ {memberRequestsEnabled && ( +
+
+ {t("settings.memberRequestsAutoDispatch")} +

{t("settings.memberRequestsAutoDispatchDesc")}

+
+ +
+ )} +
+
+ + {/* Blocker Escalation */} +
+

{t("settings.blockerEscalation")}

+
+
+
+ +
+
+
+
+ {t("settings.blockerEscalationEnabled")} +

{t("settings.blockerEscalationHint")}

+
+ +
+
+
+
+
+ + {/* Follow-up Reminders */} +
+

{t("settings.followupReminders")}

+
+
+
+ +
+
+
+ +

{t("settings.followupIntervalHint")}

+ 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" + /> +
+
+ +

{t("settings.followupMaxRemindersHint")}

+ 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" + /> +
+
+
+
+
+ + ); +} diff --git a/ui/web/src/pages/teams/team-settings-tab.tsx b/ui/web/src/pages/teams/team-settings-tab.tsx index 2b3ad6a8..f396a2c1 100644 --- a/ui/web/src/pages/teams/team-settings-tab.tsx +++ b/ui/web/src/pages/teams/team-settings-tab.tsx @@ -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 ( -
- { - if (val && !selected.includes(val)) { - onChange([...selected, val]); - } - }} - options={options.filter((o) => !selected.includes(o.value))} - placeholder={placeholder} - /> - {selected.length > 0 && ( -
- {selected.map((id) => ( - - {options.find((o) => o.value === id)?.label ?? id} - - - ))} -
- )} -
- ); -} - 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(initial.allow_user_ids ?? []); const [denyUserIds, setDenyUserIds] = useState(initial.deny_user_ids ?? []); @@ -89,10 +43,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) const [followupInterval, setFollowupInterval] = useState(initial.followup_interval_minutes ?? 30); const [followupMaxReminders, setFollowupMaxReminders] = useState(initial.followup_max_reminders ?? 0); const [workspaceScope, setWorkspaceScope] = useState(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 (
- {/* Notifications */} -
-

{t("settings.notifications")}

-
-
-
- -
-
-
-
- {t("settings.notifyDispatched")} -

{t("settings.notifyDispatchedHint")}

-
- -
-
-
- {t("settings.notifyProgress")} -

{t("settings.notifyProgressHint")}

-
- -
-
-
- {t("settings.notifyFailed")} -

{t("settings.notifyFailedHint")}

-
- -
-
-
- {t("settings.notifyCompleted")} -
- -
-
-
- {t("settings.notifyCommented")} -
- -
-
-
- {t("settings.notifyNewTask")} -
- -
-
-
- {t("settings.notifySlowTool")} -

{t("settings.notifySlowToolHint")}

-
- -
-
- {t("settings.notifyMode")} -
- {([ - { value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" }, - { value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" }, - ]).map((opt) => ( - - ))} -
- {notifyMode === "leader" && ( -

- ⚠️ {t("settings.notifyModeLeaderWarning")} -

- )} -
-
-
-
-
+ - {/* Workspace Scope */} -
-

{t("settings.workspace")}

-
-
-
- -
-
-
- {t("settings.workspaceScope")} -

- {t("settings.workspaceScopeHint")} -

-
-
- {([ - { value: "isolated", Icon: FolderLock, labelKey: "workspaceScopeIsolated", descKey: "workspaceScopeIsolatedDesc" }, - { value: "shared", Icon: FolderSync, labelKey: "workspaceScopeShared", descKey: "workspaceScopeSharedDesc" }, - ] as const).map((opt) => ( - - ))} -
-
-
-
-
+ - {/* Member Requests */} -
-

{t("settings.memberRequests")}

-
-
-
- {t("settings.memberRequestsEnabled")} -

{t("settings.memberRequestsEnabledDesc")}

-
- -
- {memberRequestsEnabled && ( -
-
- {t("settings.memberRequestsAutoDispatch")} -

{t("settings.memberRequestsAutoDispatchDesc")}

-
- -
- )} -
-
- - {/* Blocker Escalation */} -
-

{t("settings.blockerEscalation")}

-
-
-
- -
-
-
-
- {t("settings.blockerEscalationEnabled")} -

- {t("settings.blockerEscalationHint")} -

-
- -
-
-
-
-
- - {/* Follow-up Reminders */} -
-

{t("settings.followupReminders")}

-
-
-
- -
-
-
- -

- {t("settings.followupIntervalHint")} -

- 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" - /> -
-
- -

- {t("settings.followupMaxRemindersHint")} -

- 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" - /> -
-
-
-
-
- - {/* User Access Control */} -
-

{t("settings.userAccessControl")}

-
-
- -

- {t("settings.allowedUsersHint")} -

- -
-
- -

- {t("settings.deniedUsersHint")} -

- -
-
-
- - {/* Channel Restrictions */} -
-

{t("settings.channelRestrictions")}

-
-
- -

- {t("settings.allowedChannelsHint")} -

- -
-
- -

- {t("settings.deniedChannelsHint")} -

- -
-
-
+ {/* Save button */}
@@ -460,7 +149,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) {saving ? t("settings.saving") : t("settings.save")}
-
); } diff --git a/ui/web/src/pages/traces/trace-detail-dialog.tsx b/ui/web/src/pages/traces/trace-detail-dialog.tsx index 056437b8..64bba6a0 100644 --- a/ui/web/src/pages/traces/trace-detail-dialog.tsx +++ b/ui/web/src/pages/traces/trace-detail-dialog.tsx @@ -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(); - 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, {t("detail.title")} - - - {trace && (trace.status === "running") && onAbortRun && ( - )}
- {loading && !trace ? ( -
-
-
- ) : !trace ? ( -

{t("detail.notFound")}

- ) : ( -
- {/* Trace summary */} -
-
- {t("detail.name")}{" "} - {trace.name || t("unnamed")} -
-
- {t("detail.status")}{" "} - -
-
- {t("detail.duration")}{" "} - {formatDuration(trace.duration_ms || computeDurationMs(trace.start_time, trace.end_time))} -
-
- {t("detail.channel")}{" "} - {trace.channel || "—"} -
-
- {t("detail.tokens")}{" "} - {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) && ( - - {(trace.metadata?.total_cache_read_tokens ?? 0) > 0 && ( - {formatTokens(trace.metadata!.total_cache_read_tokens!)} {t("span.cached")} - )} - - )} -
-
- {t("detail.spans")}{" "} - {trace.span_count} ({trace.llm_call_count} {t("detail.llmCalls")}, {trace.tool_call_count} {t("detail.toolCalls")}) -
-
- {t("detail.started")}{" "} - {formatDate(trace.start_time, tz)} -
-
- {t("detail.createdAt")}{" "} - {formatDate(trace.created_at, tz)} -
- {trace.parent_trace_id && ( + {loading && !trace ? ( +
+ ) : !trace ? ( +

{t("detail.notFound")}

+ ) : ( +
+ + + {trace.input_preview && } + {trace.output_preview && } + {trace.error && ( +
+

{trace.error}

+
+ )} + + {spans.length > 0 && (
- {t("detail.delegatedFrom")}{" "} - +

{t("detail.spansCount", { count: spans.length })}

+
+ {spanTree.map((node) => )} +
)}
- - {trace.input_preview && ( - - )} - - {trace.output_preview && ( - - )} - - {trace.error && ( -
-

{trace.error}

-
- )} - - {/* Span tree */} - {spans.length > 0 && ( -
-

{t("detail.spansCount", { count: spans.length })}

-
- {spanTree.map((node) => ( - - ))} -
-
- )} -
- )} + )}
); } -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 ( -
-
-
- {/* Tree toggle */} - {hasChildren ? ( - - ) : ( - - )} - - {/* Span info row - clickable for detail */} - -
- - {/* Expanded detail panel */} - {detailOpen && ( -
-
- {span.start_time && ( -
- {t("span.startTime")} {formatDate(span.start_time, tz)} -
- )} - {span.end_time && ( -
- {t("span.endTime")} {formatDate(span.end_time, tz)} -
- )} - {span.model && ( -
- {t("span.model")} {span.provider}/{span.model} -
- )} -
- {(span.input_tokens > 0 || span.output_tokens > 0) && ( -
- {t("span.tokens")}{" "} - {formatTokens(span.input_tokens)} in / {formatTokens(span.output_tokens)} out - {((span.metadata?.cache_creation_tokens ?? 0) > 0 || (span.metadata?.cache_read_tokens ?? 0) > 0) && ( - - (cache: - {(span.metadata?.cache_read_tokens ?? 0) > 0 && ( - {formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cacheRead")} - )} - {(span.metadata?.cache_creation_tokens ?? 0) > 0 && ( - {formatTokens(span.metadata!.cache_creation_tokens!)} {t("span.cacheWrite")} - )} - ) - - )} - {(span.metadata?.thinking_tokens ?? 0) > 0 && ( - - ({formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")}) - - )} -
- )} - {span.metadata?.reasoning && ( -
- {t("span.reasoning")}{" "} - {span.metadata.reasoning.requested_effort ? ( - - {t("span.requested")} {span.metadata.reasoning.requested_effort} - - ) : null} - {span.metadata.reasoning.source ? ( - - {t("span.source")} {t(`span.sourceValue.${span.metadata.reasoning.source}`)} - - ) : null} - {span.metadata.reasoning.effective_effort ? ( - - {t("span.effective")} {span.metadata.reasoning.effective_effort} - - ) : null} - {span.metadata.reasoning.fallback ? ( - - {t("span.fallback")} {span.metadata.reasoning.fallback} - - ) : null} - {span.metadata.reasoning.used_provider_default ? ( - {t("span.modelDefault")} - ) : null} - {span.metadata.reasoning.reason ? ( -
{span.metadata.reasoning.reason}
- ) : null} - {span.metadata.reasoning.supported_levels?.length ? ( -
- {t("span.supportedLevels")} {span.metadata.reasoning.supported_levels.join(", ")} -
- ) : null} -
- )} - {span.input_preview && ( - - )} - {span.output_preview && ( - - )} - {span.error && ( -

{span.error}

- )} -
+
+
{t("detail.name")} {trace.name || t("unnamed")}
+
{t("detail.status")}
+
{t("detail.duration")} {formatDuration(trace.duration_ms || computeDurationMs(trace.start_time, trace.end_time))}
+
{t("detail.channel")} {trace.channel || "—"}
+
+ {t("detail.tokens")}{" "} + {formatTokens(trace.total_input_tokens)} in / {formatTokens(trace.total_output_tokens)} out + {((trace.metadata?.total_cache_read_tokens ?? 0) > 0) && ( + {formatTokens(trace.metadata!.total_cache_read_tokens!)} {t("span.cached")} )}
- - {/* Render children when tree is expanded */} - {expanded && children.map((child) => ( - - ))} -
- ); -} - -/** 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 ( -
-

{label}

- - {highlightedHtml ? ( -
-      ) : (
-        
{formatted}
+
{t("detail.spans")} {trace.span_count} ({trace.llm_call_count} {t("detail.llmCalls")}, {trace.tool_call_count} {t("detail.toolCalls")})
+
{t("detail.started")} {formatDate(trace.start_time, tz)}
+
{t("detail.createdAt")} {formatDate(trace.created_at, tz)}
+ {trace.parent_trace_id && ( +
+ {t("detail.delegatedFrom")}{" "} + +
)}
); } - -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 ( - - - {status || "unknown"} - - ); -} diff --git a/ui/web/src/pages/traces/trace-preview-block.tsx b/ui/web/src/pages/traces/trace-preview-block.tsx new file mode 100644 index 00000000..10c3f207 --- /dev/null +++ b/ui/web/src/pages/traces/trace-preview-block.tsx @@ -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 ( +
+

{label}

+ + {highlightedHtml ? ( +
+      ) : (
+        
{formatted}
+ )} +
+ ); +} diff --git a/ui/web/src/pages/traces/trace-span-tree-node.tsx b/ui/web/src/pages/traces/trace-span-tree-node.tsx new file mode 100644 index 00000000..4cd99060 --- /dev/null +++ b/ui/web/src/pages/traces/trace-span-tree-node.tsx @@ -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(); + 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 ( + + + {status || "unknown"} + + ); +} + +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 ( +
+
+
+ {hasChildren ? ( + + ) : ( + + )} + + +
+ + {detailOpen && } +
+ + {expanded && children.map((child) => ( + + ))} +
+ ); +} + +/** 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 ( +
+
+ {span.start_time &&
{t("span.startTime")} {formatDate(span.start_time, tz)}
} + {span.end_time &&
{t("span.endTime")} {formatDate(span.end_time, tz)}
} + {span.model &&
{t("span.model")} {span.provider}/{span.model}
} +
+ {(span.input_tokens > 0 || span.output_tokens > 0) && ( +
+ {t("span.tokens")}{" "} + {formatTokens(span.input_tokens)} in / {formatTokens(span.output_tokens)} out + {((span.metadata?.cache_creation_tokens ?? 0) > 0 || (span.metadata?.cache_read_tokens ?? 0) > 0) && ( + + (cache: + {(span.metadata?.cache_read_tokens ?? 0) > 0 && {formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cacheRead")}} + {(span.metadata?.cache_creation_tokens ?? 0) > 0 && {formatTokens(span.metadata!.cache_creation_tokens!)} {t("span.cacheWrite")}} + ) + + )} + {(span.metadata?.thinking_tokens ?? 0) > 0 && ( + ({formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")}) + )} +
+ )} + {reasoning && ( +
+ {t("span.reasoning")}{" "} + {reasoning.requested_effort ? {t("span.requested")} {reasoning.requested_effort} : null} + {reasoning.source ? {t("span.source")} {t(`span.sourceValue.${reasoning.source}`)} : null} + {reasoning.effective_effort ? {t("span.effective")} {reasoning.effective_effort} : null} + {reasoning.fallback ? {t("span.fallback")} {reasoning.fallback} : null} + {reasoning.used_provider_default ? {t("span.modelDefault")} : null} + {reasoning.reason ?
{reasoning.reason}
: null} + {reasoning.supported_levels?.length ?
{t("span.supportedLevels")} {reasoning.supported_levels.join(", ")}
: null} +
+ )} + {span.input_preview && } + {span.output_preview && } + {span.error &&

{span.error}

} +
+ ); +}