mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[Haik]: pt3 of going thru knip errors
This commit is contained in:
@@ -12,6 +12,9 @@
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "^0.1.9",
|
||||
"@assistant-ui/core": "^0.1.9",
|
||||
"@assistant-ui/core": "^0.1.9",
|
||||
"@assistant-ui/react": "^0.12.21",
|
||||
"@assistant-ui/react-lexical": "^0.0.3",
|
||||
"@assistant-ui/react-markdown": "^0.12.7",
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
export function formatTokenCount(n: number): string {
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useComposerAttachments } from './useComposerAttachments';
|
||||
import { MentionSelectOverride, MentionPopover, ComposerAttachmentChips } from './ComposerParts';
|
||||
import ModelModeSelector from '../ModelModeSelector';
|
||||
|
||||
export interface OpenSwarmComposerProps {
|
||||
interface OpenSwarmComposerProps {
|
||||
composerExtrasRef: MutableRefObject<ComposerExtras>;
|
||||
mode: string;
|
||||
onModeChange: (mode: string) => void;
|
||||
|
||||
@@ -2,19 +2,19 @@ import { useState, useCallback, useRef } from 'react';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
|
||||
export interface AttachedImage {
|
||||
interface AttachedImage {
|
||||
data: string;
|
||||
media_type: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface ForcedToolGroup {
|
||||
interface ForcedToolGroup {
|
||||
label: string;
|
||||
tools: string[];
|
||||
iconKey?: string;
|
||||
}
|
||||
|
||||
export interface AttachedSkill {
|
||||
interface AttachedSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMemo, type MutableRefObject } from 'react';
|
||||
import { useAui } from '@assistant-ui/react';
|
||||
import type { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
import type { ComposerExtras } from '../runtime/useOpenSwarmRuntime';
|
||||
|
||||
export interface ForcedToolGroup {
|
||||
label: string;
|
||||
tools: string[];
|
||||
iconKey?: string;
|
||||
}
|
||||
|
||||
export interface ComposerHandle {
|
||||
getConfig: () => {
|
||||
prompt: string;
|
||||
contextPaths: ContextPath[];
|
||||
forcedTools: ForcedToolGroup[];
|
||||
};
|
||||
setContent: (
|
||||
prompt: string,
|
||||
contextPaths?: ContextPath[],
|
||||
forcedTools?: ForcedToolGroup[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a ChatInputHandle-compatible interface backed by the assistant-ui
|
||||
* ComposerRuntime and a shared ComposerExtras ref.
|
||||
*
|
||||
* Agent 7 can wire this into useAgentChat to replace the old chatInputRef.
|
||||
*/
|
||||
export function useComposerHandle(
|
||||
composerExtrasRef: MutableRefObject<ComposerExtras>,
|
||||
): ComposerHandle {
|
||||
const aui = useAui();
|
||||
|
||||
return useMemo<ComposerHandle>(
|
||||
() => ({
|
||||
getConfig: () => {
|
||||
const text = aui.composer().getState().text;
|
||||
const extras = composerExtrasRef.current;
|
||||
const contextPaths: ContextPath[] = (extras.contextPaths ?? []) as ContextPath[];
|
||||
const forcedTools: ForcedToolGroup[] = extras.forcedTools
|
||||
? extras.forcedTools.map((t) => ({ label: t, tools: [t] }))
|
||||
: [];
|
||||
return { prompt: text, contextPaths, forcedTools };
|
||||
},
|
||||
setContent: (prompt, contextPaths, forcedTools) => {
|
||||
aui.composer().setText(prompt);
|
||||
composerExtrasRef.current = {
|
||||
...composerExtrasRef.current,
|
||||
contextPaths: contextPaths as ComposerExtras['contextPaths'],
|
||||
forcedTools: forcedTools?.flatMap((g) => g.tools),
|
||||
};
|
||||
},
|
||||
}),
|
||||
[aui, composerExtrasRef],
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type { AttachedImage } from '../ImageAttachments';
|
||||
import type { ForcedToolGroup } from '../AttachmentChips';
|
||||
|
||||
export interface ChatSubmitParams {
|
||||
interface ChatSubmitParams {
|
||||
editorRef: React.RefObject<HTMLDivElement | null>; attachedSkillsRef: React.MutableRefObject<Record<string, AttachedSkill>>;
|
||||
generalFileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
disabled?: boolean; autoRunMode?: boolean; images: AttachedImage[]; contextPaths: ContextPath[];
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface DispatchableMessage {
|
||||
selectedBrowserIds?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeOptions {
|
||||
interface RuntimeOptions {
|
||||
composerExtrasRef?: MutableRefObject<ComposerExtras>;
|
||||
dispatchMessage?: (msg: DispatchableMessage) => void;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const BranchChatContext = createContext<
|
||||
>(undefined);
|
||||
export const useBranchChatCallback = () => useContext(BranchChatContext);
|
||||
|
||||
export interface OpenSwarmThreadProps {
|
||||
interface OpenSwarmThreadProps {
|
||||
sessionId?: string;
|
||||
onBranchChat?: (newSessionId: string) => void;
|
||||
children?: ReactNode;
|
||||
|
||||
@@ -1,54 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Toolkit } from '@assistant-ui/react';
|
||||
import { MessageDraft } from '@/components/tool-ui/message-draft';
|
||||
import { DataTable } from '@/components/tool-ui/data-table';
|
||||
import { DataTable } from '@/components/tool-ui/data-table/data-table';
|
||||
import {
|
||||
parseMcpToolName, getGmailHeader, formatTimestamp, stripHtml,
|
||||
getGmailHeader, formatTimestamp, stripHtml,
|
||||
} from '../toolCallUtils';
|
||||
|
||||
export { BrowserFeedTracker } from './mcp-browser-feed';
|
||||
|
||||
// -- Helpers ----------------------------------------------------------------
|
||||
|
||||
/** Unwrap MCP result (string / content-block array / object) into data. */
|
||||
export function extractMcpData(result: unknown): Record<string, any> {
|
||||
if (result == null) return {};
|
||||
|
||||
let text: string | undefined;
|
||||
|
||||
if (typeof result === 'string') {
|
||||
text = result;
|
||||
} else if (typeof result === 'object') {
|
||||
const r = result as Record<string, unknown>;
|
||||
for (const k of ['text', 'output', 'content', 'result']) {
|
||||
if (typeof r[k] === 'string') { text = r[k] as string; break; }
|
||||
}
|
||||
if (text === undefined) return result as Record<string, any>;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!text) return {};
|
||||
|
||||
try {
|
||||
let parsed = JSON.parse(text);
|
||||
if (
|
||||
Array.isArray(parsed) &&
|
||||
parsed.some((b: any) => b?.type === 'text' && typeof b?.text === 'string')
|
||||
) {
|
||||
const joined = parsed
|
||||
.filter((b: any) => b?.type === 'text')
|
||||
.map((b: any) => b.text)
|
||||
.join('\n');
|
||||
try { parsed = JSON.parse(joined); } catch { return {}; }
|
||||
}
|
||||
if (typeof parsed === 'object' && parsed !== null) return parsed;
|
||||
} catch { /* not JSON */ }
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function extractEmailFields(msg: any) {
|
||||
function extractEmailFields(msg: any) {
|
||||
const subject = msg.subject || getGmailHeader(msg, 'Subject') || '(no subject)';
|
||||
const from = msg.from || msg.sender || getGmailHeader(msg, 'From') || '';
|
||||
const to = msg.to || msg.recipient || getGmailHeader(msg, 'To') || '';
|
||||
@@ -210,15 +172,6 @@ export function renderParsedMcpData(
|
||||
}
|
||||
}
|
||||
|
||||
/** Full MCP dispatch — parses raw tool name + result, routes to renderer. */
|
||||
export function renderMcpResult(
|
||||
toolName: string, result: unknown, toolCallId: string,
|
||||
): ReactNode | null {
|
||||
const mcpInfo = parseMcpToolName(toolName);
|
||||
const data = extractMcpData(result);
|
||||
return renderParsedMcpData(mcpInfo.service, mcpInfo.action, data, toolCallId);
|
||||
}
|
||||
|
||||
// -- Exported toolkit (empty — MCP tool names are dynamic; Agent 7 wires) ---
|
||||
|
||||
export const mcpToolkit: Partial<Toolkit> = {};
|
||||
|
||||
@@ -14,14 +14,14 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface FileTreeNode {
|
||||
interface FileTreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
children?: FileTreeNode[];
|
||||
}
|
||||
|
||||
export function getFileIcon(filename: string): React.ReactNode {
|
||||
function getFileIcon(filename: string): React.ReactNode {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
const size = 15;
|
||||
switch (ext) {
|
||||
@@ -74,7 +74,7 @@ export function buildFileTree(filePaths: string[]): FileTreeNode[] {
|
||||
return root;
|
||||
}
|
||||
|
||||
export interface FileTreeItemProps {
|
||||
interface FileTreeItemProps {
|
||||
node: FileTreeNode;
|
||||
depth: number;
|
||||
activeFile: string;
|
||||
@@ -83,7 +83,7 @@ export interface FileTreeItemProps {
|
||||
c: ReturnType<typeof useClaudeTokens>;
|
||||
}
|
||||
|
||||
export const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']);
|
||||
const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']);
|
||||
|
||||
export const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth, activeFile, onSelect, onDelete, c }) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
export { cn } from "@/lib/utils";
|
||||
export { Button } from "@/components/ui/button";
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
export {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
||||
@@ -35,7 +35,7 @@ import type {
|
||||
} from "./types";
|
||||
import type { FormatConfig } from "./formatters";
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US" as const;
|
||||
const DEFAULT_LOCALE = "en-US" as const;
|
||||
|
||||
function isNumericFormat(format?: FormatConfig): boolean {
|
||||
const kind = format?.kind;
|
||||
@@ -60,8 +60,8 @@ const DataTableContext = React.createContext<
|
||||
DataTableContextValue<any> | undefined
|
||||
>(undefined);
|
||||
|
||||
export function useDataTable<T extends object = RowData>() {
|
||||
const context = React.use(DataTableContext) as
|
||||
function useDataTable<T extends object = RowData>() {
|
||||
const context = React.useContext(DataTableContext) as
|
||||
| DataTableContextValue<T>
|
||||
| undefined;
|
||||
if (!context) {
|
||||
|
||||
@@ -2,7 +2,48 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
|
||||
import { resolveSafeNavigationHref } from "../shared/media";
|
||||
|
||||
function sanitizeHref(href?: string): string | undefined {
|
||||
if (!href) return undefined;
|
||||
const candidate = href.trim();
|
||||
if (!candidate) return undefined;
|
||||
|
||||
if (
|
||||
candidate.startsWith("/") ||
|
||||
candidate.startsWith("./") ||
|
||||
candidate.startsWith("../") ||
|
||||
candidate.startsWith("?") ||
|
||||
candidate.startsWith("#")
|
||||
) {
|
||||
if (candidate.startsWith("//")) return undefined;
|
||||
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
|
||||
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveSafeNavigationHref(
|
||||
...candidates: Array<string | null | undefined>
|
||||
): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const safeHref = sanitizeHref(candidate ?? undefined);
|
||||
if (safeHref) {
|
||||
return safeHref;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type Tone = "success" | "warning" | "danger" | "info" | "neutral";
|
||||
|
||||
@@ -44,7 +85,7 @@ interface DeltaValueProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function DeltaValue({ value, options, locale }: DeltaValueProps) {
|
||||
function DeltaValue({ value, options, locale }: DeltaValueProps) {
|
||||
const decimals = options?.decimals ?? 2;
|
||||
const upIsPositive = options?.upIsPositive ?? true;
|
||||
const showSign = options?.showSign ?? true;
|
||||
@@ -90,7 +131,7 @@ interface StatusBadgeProps {
|
||||
options?: Extract<FormatConfig, { kind: "status" }>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ value, options }: StatusBadgeProps) {
|
||||
function StatusBadge({ value, options }: StatusBadgeProps) {
|
||||
const config = options?.statusMap?.[value] ?? {
|
||||
tone: "neutral" as Tone,
|
||||
label: value,
|
||||
@@ -130,7 +171,7 @@ interface CurrencyValueProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
|
||||
function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
|
||||
const currency = options?.currency ?? "USD";
|
||||
const decimals = options?.decimals ?? 2;
|
||||
|
||||
@@ -150,7 +191,7 @@ interface PercentValueProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function PercentValue({ value, options, locale }: PercentValueProps) {
|
||||
function PercentValue({ value, options, locale }: PercentValueProps) {
|
||||
const decimals = options?.decimals ?? 2;
|
||||
const showSign = options?.showSign ?? false;
|
||||
const basis = options?.basis ?? "fraction";
|
||||
@@ -173,7 +214,7 @@ interface DateValueProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function DateValue({ value, options, locale }: DateValueProps) {
|
||||
function DateValue({ value, options, locale }: DateValueProps) {
|
||||
const dateFormat = options?.dateFormat ?? "short";
|
||||
const date = new Date(value);
|
||||
|
||||
@@ -248,7 +289,7 @@ interface BooleanValueProps {
|
||||
options?: Extract<FormatConfig, { kind: "boolean" }>;
|
||||
}
|
||||
|
||||
export function BooleanValue({ value, options }: BooleanValueProps) {
|
||||
function BooleanValue({ value, options }: BooleanValueProps) {
|
||||
const labels = options?.labels ?? { true: "Yes", false: "No" };
|
||||
const label = value ? labels.true : labels.false;
|
||||
const variant = value ? "secondary" : "outline";
|
||||
@@ -265,7 +306,7 @@ interface LinkValueProps {
|
||||
>;
|
||||
}
|
||||
|
||||
export function LinkValue({ value, options, row }: LinkValueProps) {
|
||||
function LinkValue({ value, options, row }: LinkValueProps) {
|
||||
const rawHref =
|
||||
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
|
||||
const href = resolveSafeNavigationHref(rawHref);
|
||||
@@ -300,7 +341,7 @@ interface NumberValueProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function NumberValue({ value, options, locale }: NumberValueProps) {
|
||||
function NumberValue({ value, options, locale }: NumberValueProps) {
|
||||
const decimals = options?.decimals ?? 0;
|
||||
const unit = options?.unit ?? "";
|
||||
const compact = options?.compact ?? false;
|
||||
@@ -327,7 +368,7 @@ interface BadgeValueProps {
|
||||
options?: Extract<FormatConfig, { kind: "badge" }>;
|
||||
}
|
||||
|
||||
export function BadgeValue({ value, options }: BadgeValueProps) {
|
||||
function BadgeValue({ value, options }: BadgeValueProps) {
|
||||
const tone = options?.colorMap?.[value] ?? "neutral";
|
||||
|
||||
const variant =
|
||||
@@ -362,7 +403,7 @@ interface ArrayValueProps {
|
||||
options?: Extract<FormatConfig, { kind: "array" }>;
|
||||
}
|
||||
|
||||
export function ArrayValue({ value, options }: ArrayValueProps) {
|
||||
function ArrayValue({ value, options }: ArrayValueProps) {
|
||||
const maxVisible = options?.maxVisible ?? 3;
|
||||
const items: (string | number | boolean | null)[] = Array.isArray(value)
|
||||
? value
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export { DataTable, useDataTable } from "./data-table";
|
||||
|
||||
export { renderFormattedValue } from "./formatters";
|
||||
export {
|
||||
NumberValue,
|
||||
CurrencyValue,
|
||||
PercentValue,
|
||||
DeltaValue,
|
||||
DateValue,
|
||||
BooleanValue,
|
||||
LinkValue,
|
||||
BadgeValue,
|
||||
StatusBadge,
|
||||
ArrayValue,
|
||||
} from "./formatters";
|
||||
|
||||
export type {
|
||||
Column,
|
||||
DataTableProps,
|
||||
DataTableSerializableProps,
|
||||
DataTableClientProps,
|
||||
DataTableRowData,
|
||||
RowPrimitive,
|
||||
RowData,
|
||||
ColumnKey,
|
||||
} from "./types";
|
||||
export type { FormatConfig } from "./formatters";
|
||||
|
||||
export { sortData, parseNumericLike } from "./utilities";
|
||||
@@ -1,345 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import type { Column, DataTableProps, RowData } from "./types";
|
||||
|
||||
const AlignEnum = z.enum(["left", "right", "center"]);
|
||||
const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]);
|
||||
|
||||
const formatSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("text") }),
|
||||
z.object({
|
||||
kind: z.literal("number"),
|
||||
decimals: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
compact: z.boolean().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("currency"),
|
||||
currency: z.string(),
|
||||
decimals: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("percent"),
|
||||
decimals: z.number().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
basis: z.enum(["fraction", "unit"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("date"),
|
||||
dateFormat: z.enum(["short", "long", "relative"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("delta"),
|
||||
decimals: z.number().optional(),
|
||||
upIsPositive: z.boolean().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("status"),
|
||||
statusMap: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
tone: z.enum(["success", "warning", "danger", "info", "neutral"]),
|
||||
label: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("boolean"),
|
||||
labels: z
|
||||
.object({
|
||||
true: z.string(),
|
||||
false: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("link"),
|
||||
hrefKey: z.string().optional(),
|
||||
external: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("badge"),
|
||||
colorMap: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.enum(["success", "warning", "danger", "info", "neutral"]),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("array"),
|
||||
maxVisible: z.number().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const serializableColumnSchema = z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
abbr: z.string().optional(),
|
||||
sortable: z.boolean().optional(),
|
||||
align: AlignEnum.optional(),
|
||||
width: z.string().optional(),
|
||||
truncate: z.boolean().optional(),
|
||||
priority: PriorityEnum.optional(),
|
||||
hideOnMobile: z.boolean().optional(),
|
||||
format: formatSchema.optional(),
|
||||
});
|
||||
|
||||
const JsonPrimitiveSchema = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Schema for serializable row data.
|
||||
*
|
||||
* Supports:
|
||||
* - Primitives: string, number, boolean, null
|
||||
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
|
||||
*
|
||||
* Does NOT support:
|
||||
* - Functions
|
||||
* - Class instances (Date, Map, Set, etc.)
|
||||
* - Plain objects (use format configs instead)
|
||||
*
|
||||
* @example
|
||||
* Valid row data:
|
||||
* ```json
|
||||
* {
|
||||
* "name": "Widget",
|
||||
* "price": 29.99,
|
||||
* "active": true,
|
||||
* "tags": ["electronics", "featured"],
|
||||
* "metrics": [1.2, 3.4, 5.6],
|
||||
* "flags": [true, false, true],
|
||||
* "mixed": ["label", 42, true]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const serializableDataSchema = z.record(
|
||||
z.string(),
|
||||
z.union([JsonPrimitiveSchema, z.array(JsonPrimitiveSchema)]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Zod schema for validating DataTable payloads from LLM tool calls.
|
||||
*
|
||||
* This schema validates the serializable parts of a DataTable:
|
||||
* - id: Unique identifier for this tool UI in the conversation
|
||||
* - columns: Column definitions (keys, labels, formatting, etc.)
|
||||
* - data: Data rows (primitives only - no functions or class instances)
|
||||
* - optional presentation props: rowIdKey, sort/defaultSort, locale, etc.
|
||||
*
|
||||
* Non-serializable props like `onSortChange`, `className`, and sibling action surfaces
|
||||
* must be provided separately in your React component.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = SerializableDataTableSchema.safeParse(llmResponse)
|
||||
* if (result.success) {
|
||||
* // result.data contains validated id, columns, and data
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const SerializableDataTableSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
columns: z.array(serializableColumnSchema),
|
||||
data: z.array(serializableDataSchema),
|
||||
rowIdKey: z.string().optional(),
|
||||
defaultSort: z
|
||||
.object({
|
||||
by: z.string().optional(),
|
||||
direction: z.enum(["asc", "desc"]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
sort: z
|
||||
.object({
|
||||
by: z.string().optional(),
|
||||
direction: z.enum(["asc", "desc"]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
emptyMessage: z.string().optional(),
|
||||
maxHeight: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
const SerializableDataTableSchemaContract = defineToolUiContract(
|
||||
"DataTable",
|
||||
SerializableDataTableSchema,
|
||||
);
|
||||
|
||||
/**
|
||||
* Type representing the serializable parts of a DataTable payload.
|
||||
*
|
||||
* This type includes only JSON-serializable data that can come from LLM tool calls:
|
||||
* - Column definitions (format configs, alignment, labels, etc.)
|
||||
* - Row data (primitives: strings, numbers, booleans, null, string arrays)
|
||||
*
|
||||
* Excluded from this type:
|
||||
* - Event handlers (`onSortChange`)
|
||||
* - React-specific props (`className`)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const payload: SerializableDataTable = {
|
||||
* id: "data-table-expenses",
|
||||
* columns: [
|
||||
* { key: "name", label: "Name" },
|
||||
* { key: "price", label: "Price", format: { kind: "currency", currency: "USD" } }
|
||||
* ],
|
||||
* data: [
|
||||
* { name: "Widget", price: 29.99 }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type SerializableDataTable = z.infer<typeof SerializableDataTableSchema>;
|
||||
|
||||
/**
|
||||
* Validates and parses a DataTable payload from unknown data (e.g., LLM tool call result).
|
||||
*
|
||||
* This function:
|
||||
* 1. Validates the input against the `SerializableDataTableSchema`
|
||||
* 2. Throws a descriptive error if validation fails
|
||||
* 3. Returns typed serializable props ready to pass to the `<DataTable>` component
|
||||
*
|
||||
* The returned props are **serializable only** - you must provide client-side props
|
||||
* separately (onSortChange, className).
|
||||
*
|
||||
* @param input - Unknown data to validate (typically from an LLM tool call)
|
||||
* @returns Validated and typed DataTable serializable props (id, columns, data)
|
||||
* @throws Error with validation details if input is invalid
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyToolUI({ result }: { result: unknown }) {
|
||||
* const serializableProps = parseSerializableDataTable(result)
|
||||
*
|
||||
* return (
|
||||
* <DataTable
|
||||
* {...serializableProps}
|
||||
* />
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function parseSerializableDataTable(
|
||||
input: unknown,
|
||||
): Pick<
|
||||
DataTableProps<RowData>,
|
||||
| "id"
|
||||
| "role"
|
||||
| "receipt"
|
||||
| "columns"
|
||||
| "data"
|
||||
| "rowIdKey"
|
||||
| "defaultSort"
|
||||
| "sort"
|
||||
| "emptyMessage"
|
||||
| "maxHeight"
|
||||
| "locale"
|
||||
> {
|
||||
const {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns,
|
||||
data,
|
||||
rowIdKey,
|
||||
defaultSort,
|
||||
sort,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
locale,
|
||||
} = SerializableDataTableSchemaContract.parse(input);
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns: columns as unknown as Column<RowData>[],
|
||||
data: data as RowData[],
|
||||
rowIdKey: rowIdKey as keyof RowData | undefined,
|
||||
defaultSort: defaultSort
|
||||
? {
|
||||
by: defaultSort.by as keyof RowData | undefined,
|
||||
direction: defaultSort.direction,
|
||||
}
|
||||
: undefined,
|
||||
sort: sort
|
||||
? {
|
||||
by: sort.by as keyof RowData | undefined,
|
||||
direction: sort.direction,
|
||||
}
|
||||
: undefined,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
locale,
|
||||
};
|
||||
}
|
||||
|
||||
export function safeParseSerializableDataTable(
|
||||
input: unknown,
|
||||
): Pick<
|
||||
DataTableProps<RowData>,
|
||||
| "id"
|
||||
| "role"
|
||||
| "receipt"
|
||||
| "columns"
|
||||
| "data"
|
||||
| "rowIdKey"
|
||||
| "defaultSort"
|
||||
| "sort"
|
||||
| "emptyMessage"
|
||||
| "maxHeight"
|
||||
| "locale"
|
||||
> | null {
|
||||
const res = SerializableDataTableSchemaContract.safeParse(input);
|
||||
if (!res) return null;
|
||||
const {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns,
|
||||
data,
|
||||
rowIdKey,
|
||||
defaultSort,
|
||||
sort,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
locale,
|
||||
} = res;
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns: columns as unknown as Column<RowData>[],
|
||||
data: data as RowData[],
|
||||
rowIdKey: rowIdKey as keyof RowData | undefined,
|
||||
defaultSort: defaultSort
|
||||
? {
|
||||
by: defaultSort.by as keyof RowData | undefined,
|
||||
direction: defaultSort.direction,
|
||||
}
|
||||
: undefined,
|
||||
sort: sort
|
||||
? {
|
||||
by: sort.by as keyof RowData | undefined,
|
||||
direction: sort.direction,
|
||||
}
|
||||
: undefined,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
locale,
|
||||
};
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function sortData<T, K extends Extract<keyof T, string>>(
|
||||
* Accepts any JSON-serializable primitive or array of primitives.
|
||||
* Arrays are converted to comma-separated strings.
|
||||
*/
|
||||
export function getRowIdentifier(
|
||||
function getRowIdentifier(
|
||||
row: Record<
|
||||
string,
|
||||
string | number | boolean | null | (string | number | boolean | null)[]
|
||||
@@ -210,7 +210,7 @@ export function getDataTableMobileDescriptionId(surfaceId: string): string {
|
||||
* parseNumericLike("50%") // 50
|
||||
* parseNumericLike("(1234)") // -1234
|
||||
*/
|
||||
export function parseNumericLike(input: string): number | null {
|
||||
function parseNumericLike(input: string): number | null {
|
||||
// Normalize whitespace (spaces, NBSPs, thin spaces)
|
||||
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
|
||||
if (!s) return null;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AspectRatioSchema = z
|
||||
.enum(["auto", "1:1", "4:3", "16:9", "9:16"])
|
||||
.default("auto");
|
||||
|
||||
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
|
||||
export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");
|
||||
|
||||
export type MediaFit = z.infer<typeof MediaFitSchema>;
|
||||
|
||||
export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
|
||||
auto: "",
|
||||
"1:1": "aspect-square",
|
||||
"4:3": "aspect-[4/3]",
|
||||
"16:9": "aspect-video",
|
||||
"9:16": "aspect-[9/16]",
|
||||
};
|
||||
|
||||
export function getRatioClass(ratio: AspectRatio): string {
|
||||
return RATIO_CLASS_MAP[ratio];
|
||||
}
|
||||
|
||||
export function getFitClass(fit: MediaFit): string {
|
||||
return fit === "cover" ? "object-cover" : "object-contain";
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
export function formatDuration(durationMs: number): string {
|
||||
const totalSeconds = Math.round(durationMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human-readable string.
|
||||
* @example formatFileSize(1024) => "1 KB"
|
||||
* @example formatFileSize(1536000) => "1.5 MB"
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let size = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export {
|
||||
AspectRatioSchema,
|
||||
MediaFitSchema,
|
||||
RATIO_CLASS_MAP,
|
||||
getRatioClass,
|
||||
getFitClass,
|
||||
type AspectRatio,
|
||||
type MediaFit,
|
||||
} from "./aspect-ratio";
|
||||
|
||||
export { OVERLAY_GRADIENT } from "./overlay-gradient";
|
||||
|
||||
export { formatDuration, formatFileSize } from "./format-utils";
|
||||
|
||||
export { sanitizeHref } from "./sanitize-href";
|
||||
export {
|
||||
resolveSafeNavigationHref,
|
||||
openSafeNavigationHref,
|
||||
} from "./safe-navigation";
|
||||
@@ -1,19 +0,0 @@
|
||||
export const OVERLAY_GRADIENT = `linear-gradient(
|
||||
to bottom,
|
||||
hsl(0, 0%, 0%) 0%,
|
||||
hsla(0, 0%, 0%, 0.987) 8.3%,
|
||||
hsla(0, 0%, 0%, 0.951) 16.6%,
|
||||
hsla(0, 0%, 0%, 0.896) 24.6%,
|
||||
hsla(0, 0%, 0%, 0.825) 32.5%,
|
||||
hsla(0, 0%, 0%, 0.741) 40.1%,
|
||||
hsla(0, 0%, 0%, 0.648) 47.6%,
|
||||
hsla(0, 0%, 0%, 0.55) 54.8%,
|
||||
hsla(0, 0%, 0%, 0.45) 61.7%,
|
||||
hsla(0, 0%, 0%, 0.352) 68.3%,
|
||||
hsla(0, 0%, 0%, 0.259) 74.5%,
|
||||
hsla(0, 0%, 0%, 0.175) 80.4%,
|
||||
hsla(0, 0%, 0%, 0.104) 86%,
|
||||
hsla(0, 0%, 0%, 0.049) 91.1%,
|
||||
hsla(0, 0%, 0%, 0.013) 95.8%,
|
||||
hsla(0, 0%, 0%, 0) 100%
|
||||
)` as const;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { sanitizeHref } from "./sanitize-href";
|
||||
|
||||
export function resolveSafeNavigationHref(
|
||||
...candidates: Array<string | null | undefined>
|
||||
): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const safeHref = sanitizeHref(candidate ?? undefined);
|
||||
if (safeHref) {
|
||||
return safeHref;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function openSafeNavigationHref(href: string | undefined): boolean {
|
||||
if (!href || typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
return true;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export function sanitizeHref(href?: string): string | undefined {
|
||||
if (!href) return undefined;
|
||||
const candidate = href.trim();
|
||||
if (!candidate) return undefined;
|
||||
|
||||
if (
|
||||
candidate.startsWith("/") ||
|
||||
candidate.startsWith("./") ||
|
||||
candidate.startsWith("../") ||
|
||||
candidate.startsWith("?") ||
|
||||
candidate.startsWith("#")
|
||||
) {
|
||||
if (candidate.startsWith("//")) return undefined;
|
||||
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
|
||||
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@@ -23,12 +22,6 @@ function DialogPortal({
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
@@ -79,43 +72,6 @@ function DialogContent({
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
@@ -129,28 +85,9 @@ function DialogTitle({
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user