mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[Haik]: Agentic refactor 3. Composer & Mentions i.e. user input
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.12.21",
|
||||
"@assistant-ui/react-lexical": "^0.0.3",
|
||||
"@assistant-ui/react-markdown": "^0.12.7",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface SlashItem {
|
||||
id: string;
|
||||
type: 'template' | 'skill';
|
||||
name: string;
|
||||
description: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filter: string;
|
||||
onSelect: (item: SlashItem) => void;
|
||||
onClose: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const SlashCommandPicker: React.FC<Props> = ({ filter, onSelect, onClose, visible }) => {
|
||||
const c = useClaudeTokens();
|
||||
const templates = useAppSelector((state) => state.templates.items);
|
||||
const skills = useAppSelector((state) => state.skills.items);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const items: SlashItem[] = useMemo(() => {
|
||||
const all: SlashItem[] = [
|
||||
...Object.values(templates).map((t) => ({
|
||||
id: t.id,
|
||||
type: 'template' as const,
|
||||
name: t.name,
|
||||
description: t.description || `Template with ${t.fields.length} fields`,
|
||||
command: t.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
})),
|
||||
...Object.values(skills).map((s) => ({
|
||||
id: s.id,
|
||||
type: 'skill' as const,
|
||||
name: s.name,
|
||||
description: s.description || 'Skill',
|
||||
command: s.command || s.id,
|
||||
})),
|
||||
];
|
||||
|
||||
if (!filter) return all;
|
||||
const lower = filter.toLowerCase();
|
||||
return all.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lower) ||
|
||||
item.command.toLowerCase().includes(lower) ||
|
||||
item.description.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [templates, skills, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && items[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(items[selectedIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [visible, items, selectedIndex, onSelect, onClose]);
|
||||
|
||||
if (!visible || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
mb: 0.5,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
maxHeight: 280,
|
||||
overflow: 'auto',
|
||||
zIndex: 1000,
|
||||
boxShadow: c.shadow.lg,
|
||||
'&::-webkit-scrollbar': { width: 5 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: c.border.medium,
|
||||
borderRadius: 3,
|
||||
'&:hover': { background: c.border.strong },
|
||||
},
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 1.5, py: 1, borderBottom: `0.5px solid ${c.border.medium}` }}>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Commands
|
||||
</Typography>
|
||||
</Box>
|
||||
<List sx={{ py: 0.5 }}>
|
||||
{items.map((item, i) => (
|
||||
<ListItemButton
|
||||
key={`${item.type}-${item.id}`}
|
||||
selected={i === selectedIndex}
|
||||
onClick={() => onSelect(item)}
|
||||
sx={{
|
||||
py: 0.75,
|
||||
px: 1.5,
|
||||
'&.Mui-selected': { bgcolor: 'rgba(174,86,48,0.06)' },
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' },
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{item.type === 'template' ? (
|
||||
<DescriptionIcon sx={{ fontSize: 18, color: c.accent.primary }} />
|
||||
) : (
|
||||
<PsychologyIcon sx={{ fontSize: 18, color: c.status.success }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>
|
||||
/{item.command}
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.7rem' }}>
|
||||
{item.type}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', mt: 0.25 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlashCommandPicker;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, type FC } from 'react';
|
||||
import {
|
||||
ComposerPrimitive,
|
||||
unstable_useMentionContextOptional,
|
||||
} from '@assistant-ui/react';
|
||||
import type { Unstable_MentionItem } from '@assistant-ui/core';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Registers a selectItemOverride callback on the nearest MentionRoot context.
|
||||
* Must be rendered inside a ComposerPrimitive.Unstable_MentionRoot.
|
||||
* Returns true from the callback to prevent the default mention directive insertion.
|
||||
*/
|
||||
export const MentionSelectOverride: FC<{
|
||||
onSelect: (item: Unstable_MentionItem) => boolean;
|
||||
}> = ({ onSelect }) => {
|
||||
const ctx = unstable_useMentionContextOptional();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) return;
|
||||
return ctx.registerSelectItemOverride(onSelect);
|
||||
}, [ctx, onSelect]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const MentionPopover: FC = () => (
|
||||
<ComposerPrimitive.Unstable_MentionPopover className="z-50 max-h-64 min-w-56 overflow-y-auto rounded-lg border bg-popover p-1 shadow-lg">
|
||||
<ComposerPrimitive.Unstable_MentionBack className="mb-1 flex w-full items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent">
|
||||
← Back
|
||||
</ComposerPrimitive.Unstable_MentionBack>
|
||||
<ComposerPrimitive.Unstable_MentionCategories>
|
||||
{(categories) =>
|
||||
categories.map((cat) => (
|
||||
<ComposerPrimitive.Unstable_MentionCategoryItem
|
||||
key={cat.id}
|
||||
categoryId={cat.id}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent data-[highlighted]:bg-accent"
|
||||
>
|
||||
{cat.label}
|
||||
</ComposerPrimitive.Unstable_MentionCategoryItem>
|
||||
))
|
||||
}
|
||||
</ComposerPrimitive.Unstable_MentionCategories>
|
||||
<ComposerPrimitive.Unstable_MentionItems>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<ComposerPrimitive.Unstable_MentionItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
className="flex w-full cursor-pointer flex-col gap-0.5 rounded px-2 py-1.5 hover:bg-accent data-[highlighted]:bg-accent"
|
||||
>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_MentionItem>
|
||||
))
|
||||
}
|
||||
</ComposerPrimitive.Unstable_MentionItems>
|
||||
</ComposerPrimitive.Unstable_MentionPopover>
|
||||
);
|
||||
|
||||
const Chip: FC<{ label: string; onRemove: () => void }> = ({ label, onRemove }) => (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
{label}
|
||||
<button onClick={onRemove} className="ml-0.5 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
export const ComposerAttachmentChips: FC<{
|
||||
images: { preview: string }[];
|
||||
contextPaths: { path: string; type: string }[];
|
||||
forcedTools: { label: string }[];
|
||||
attachedSkills: Record<string, { name: string }>;
|
||||
onRemoveImage: (idx: number) => void;
|
||||
onRemoveContextPath: (idx: number) => void;
|
||||
onRemoveForcedTool: (idx: number) => void;
|
||||
onRemoveSkill: (id: string) => void;
|
||||
}> = ({
|
||||
images, contextPaths, forcedTools, attachedSkills,
|
||||
onRemoveImage, onRemoveContextPath, onRemoveForcedTool, onRemoveSkill,
|
||||
}) => (
|
||||
<div className="flex flex-wrap gap-1 px-1">
|
||||
{images.map((img, i) => (
|
||||
<div key={`img-${i}`} className="group relative h-10 w-10 overflow-hidden rounded border">
|
||||
<img src={img.preview} alt="" className="h-full w-full object-cover" />
|
||||
<button
|
||||
onClick={() => onRemoveImage(i)}
|
||||
className="absolute -top-1 -right-1 hidden rounded-full bg-destructive p-0.5 text-destructive-foreground group-hover:block"
|
||||
>
|
||||
<XIcon className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{contextPaths.map((cp, i) => (
|
||||
<Chip key={`cp-${i}`} label={cp.path.split('/').pop() || cp.path} onRemove={() => onRemoveContextPath(i)} />
|
||||
))}
|
||||
{forcedTools.map((ft, i) => (
|
||||
<Chip key={`ft-${i}`} label={`@${ft.label}`} onRemove={() => onRemoveForcedTool(i)} />
|
||||
))}
|
||||
{Object.entries(attachedSkills).map(([id, s]) => (
|
||||
<Chip key={`sk-${id}`} label={s.name} onRemove={() => onRemoveSkill(id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1,7 +1,178 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState, type FC, type MutableRefObject } from 'react';
|
||||
import { ComposerPrimitive, useAui } from '@assistant-ui/react';
|
||||
import { LexicalComposerInput } from '@assistant-ui/react-lexical';
|
||||
import type { Unstable_MentionItem } from '@assistant-ui/core';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import type { PromptTemplate } from '@/shared/state/templatesSlice';
|
||||
import type { ComposerExtras } from '../runtime/useOpenSwarmRuntime';
|
||||
import { useOpenSwarmMentionAdapter, type MentionItemMetadata } from './OpenSwarmMentionAdapter';
|
||||
import { useComposerAttachments } from './useComposerAttachments';
|
||||
import { MentionSelectOverride, MentionPopover, ComposerAttachmentChips } from './ComposerParts';
|
||||
import TemplateInvokeModal from '../TemplateInvokeModal';
|
||||
import ModelModeSelector from '../ModelModeSelector';
|
||||
|
||||
const OpenSwarmComposer: React.FC = () => {
|
||||
return <div>Composer placeholder</div>;
|
||||
export interface OpenSwarmComposerProps {
|
||||
composerExtrasRef: MutableRefObject<ComposerExtras>;
|
||||
mode: string;
|
||||
onModeChange: (mode: string) => void;
|
||||
model: string;
|
||||
onModelChange: (model: string) => void;
|
||||
isRunning?: boolean;
|
||||
onStop?: () => void;
|
||||
sessionId?: string;
|
||||
queueLength?: number;
|
||||
contextEstimate?: { used: number; limit: number };
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const OpenSwarmComposer: FC<OpenSwarmComposerProps> = ({
|
||||
composerExtrasRef, mode, onModeChange, model, onModelChange,
|
||||
isRunning, onStop, sessionId, queueLength, contextEstimate, autoFocus,
|
||||
}) => {
|
||||
const aui = useAui();
|
||||
const mentionAdapter = useOpenSwarmMentionAdapter();
|
||||
const att = useComposerAttachments();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<PromptTemplate | null>(null);
|
||||
const [hasContent, setHasContent] = useState(false);
|
||||
|
||||
const templates = useAppSelector((s) => s.templates.items);
|
||||
const skills = useAppSelector((s) => s.skills.items);
|
||||
|
||||
const syncExtras = useCallback(() => {
|
||||
const allForcedTools = att.forcedTools.flatMap((ft) => ft.tools);
|
||||
const skillList = Object.values(att.attachedSkills);
|
||||
composerExtrasRef.current = {
|
||||
images: att.images.length > 0 ? att.images.map(({ data, media_type }) => ({ data, media_type })) : undefined,
|
||||
contextPaths: att.contextPaths.length > 0 ? att.contextPaths : undefined,
|
||||
forcedTools: allForcedTools.length > 0 ? allForcedTools : undefined,
|
||||
attachedSkills: skillList.length > 0 ? skillList : undefined,
|
||||
};
|
||||
}, [composerExtrasRef, att.images, att.contextPaths, att.forcedTools, att.attachedSkills]);
|
||||
|
||||
const handleFormSubmit = useCallback(() => {
|
||||
syncExtras();
|
||||
setTimeout(() => att.clearAll(), 0);
|
||||
}, [syncExtras, att]);
|
||||
|
||||
const handleSendClick = useCallback(() => {
|
||||
syncExtras();
|
||||
aui.composer().send();
|
||||
att.clearAll();
|
||||
}, [syncExtras, aui, att]);
|
||||
|
||||
useEffect(() => {
|
||||
return aui.subscribe(() => {
|
||||
const text = aui.composer().getState().text;
|
||||
setHasContent(text.trim().length > 0 || att.images.length > 0 || att.contextPaths.length > 0);
|
||||
});
|
||||
}, [aui, att.images.length, att.contextPaths.length]);
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(item: Unstable_MentionItem): boolean => {
|
||||
const meta = item.metadata as unknown as MentionItemMetadata | undefined;
|
||||
if (!meta) return false;
|
||||
switch (meta.itemType) {
|
||||
case 'template': {
|
||||
const tmpl = templates[item.id];
|
||||
if (!tmpl) return true;
|
||||
if (tmpl.fields.length === 0) {
|
||||
aui.composer().setText(aui.composer().getState().text + tmpl.template);
|
||||
} else {
|
||||
setSelectedTemplate(tmpl);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'skill': {
|
||||
const skill = skills[item.id];
|
||||
if (!skill) return true;
|
||||
att.setAttachedSkills((prev) => ({
|
||||
...prev,
|
||||
[skill.id]: { id: skill.id, name: skill.name, content: skill.content },
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
case 'mode':
|
||||
onModeChange(item.id);
|
||||
return true;
|
||||
case 'file':
|
||||
att.generalFileInputRef.current?.click();
|
||||
return true;
|
||||
case 'tool-group':
|
||||
case 'output':
|
||||
if (meta.toolNames && meta.toolNames.length > 0) {
|
||||
att.setForcedTools((prev) => [
|
||||
...prev,
|
||||
{ label: item.label, tools: meta.toolNames!, iconKey: meta.iconKey },
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[templates, skills, onModeChange, aui, att],
|
||||
);
|
||||
|
||||
const handleTemplateApply = useCallback(
|
||||
(rendered: string) => {
|
||||
aui.composer().setText(aui.composer().getState().text + rendered);
|
||||
setSelectedTemplate(null);
|
||||
},
|
||||
[aui],
|
||||
);
|
||||
|
||||
const hasAttachments =
|
||||
att.images.length > 0 || att.contextPaths.length > 0 ||
|
||||
att.forcedTools.length > 0 || Object.keys(att.attachedSkills).length > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-col">
|
||||
<ComposerPrimitive.Unstable_MentionRoot trigger="@" adapter={mentionAdapter}>
|
||||
<ComposerPrimitive.Root ref={formRef} onSubmit={handleFormSubmit} className="aui-composer-root relative flex w-full flex-col">
|
||||
<MentionSelectOverride onSelect={handleMentionSelect} />
|
||||
<div
|
||||
className="flex w-full flex-col gap-1 rounded-2xl border bg-background p-2 transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20"
|
||||
onDragOver={att.handleDragOver} onDragLeave={att.handleDragLeave} onDrop={att.handleDrop}
|
||||
data-dragging={att.isDragOver || undefined}
|
||||
>
|
||||
{hasAttachments && (
|
||||
<ComposerAttachmentChips
|
||||
images={att.images} contextPaths={att.contextPaths}
|
||||
forcedTools={att.forcedTools} attachedSkills={att.attachedSkills}
|
||||
onRemoveImage={att.removeImage} onRemoveContextPath={att.removeContextPath}
|
||||
onRemoveForcedTool={att.removeForcedTool} onRemoveSkill={att.removeSkill}
|
||||
/>
|
||||
)}
|
||||
<LexicalComposerInput
|
||||
placeholder="Message — @ for context and commands"
|
||||
className="aui-composer-input max-h-40 min-h-10 w-full resize-none bg-transparent px-2 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/80"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<MentionPopover />
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<ModelModeSelector
|
||||
mode={mode} onModeChange={onModeChange} model={model} onModelChange={onModelChange}
|
||||
contextEstimate={contextEstimate} ownerId={sessionId || 'composer'} sessionId={sessionId}
|
||||
hasContent={hasContent} isRunning={isRunning} onSend={handleSendClick} onStop={onStop}
|
||||
addImageFiles={att.addImageFiles} uploadAndAttachFiles={att.uploadAndAttachFiles}
|
||||
generalFileInputRef={att.generalFileInputRef} queueLength={queueLength}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.Unstable_MentionRoot>
|
||||
|
||||
<input ref={att.generalFileInputRef} type="file" multiple className="hidden" onChange={att.handleFileInputChange} />
|
||||
|
||||
{selectedTemplate && (
|
||||
<TemplateInvokeModal
|
||||
template={selectedTemplate} open={!!selectedTemplate}
|
||||
onClose={() => setSelectedTemplate(null)} onApply={handleTemplateApply}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenSwarmComposer;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import type {
|
||||
Unstable_MentionAdapter,
|
||||
Unstable_MentionCategory,
|
||||
Unstable_MentionItem,
|
||||
} from '@assistant-ui/core';
|
||||
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
|
||||
import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
|
||||
export interface MentionItemMetadata {
|
||||
itemType: 'template' | 'skill' | 'mode' | 'file' | 'tool-group' | 'output';
|
||||
toolNames?: string[];
|
||||
iconKey?: string;
|
||||
command?: string;
|
||||
hasFields?: boolean;
|
||||
}
|
||||
|
||||
function buildItem(
|
||||
id: string,
|
||||
type: string,
|
||||
label: string,
|
||||
description: string,
|
||||
meta: MentionItemMetadata,
|
||||
): Unstable_MentionItem {
|
||||
return { id, type, label, description, metadata: meta as any };
|
||||
}
|
||||
|
||||
function matchesQuery(item: Unstable_MentionItem, lower: string): boolean {
|
||||
return (
|
||||
item.label.toLowerCase().includes(lower) ||
|
||||
(item.description?.toLowerCase().includes(lower) ?? false) ||
|
||||
((item.metadata as any)?.command?.toLowerCase().includes(lower) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that builds an Unstable_MentionAdapter from Redux state.
|
||||
* Provides categories and items for templates, skills, modes, context tools,
|
||||
* MCP tool groups, and output apps.
|
||||
*/
|
||||
export function useOpenSwarmMentionAdapter(): Unstable_MentionAdapter {
|
||||
const dispatch = useAppDispatch();
|
||||
const templates = useAppSelector((s) => s.templates.items);
|
||||
const skills = useAppSelector((s) => s.skills.items);
|
||||
const modesMap = useAppSelector((s) => s.modes.items);
|
||||
const builtinTools = useAppSelector((s) => s.tools.builtinTools);
|
||||
const customTools = useAppSelector((s) => s.tools.items);
|
||||
const outputItems = useAppSelector((s) => s.outputs.items);
|
||||
|
||||
const toolsLoaded = useAppSelector((s) => s.tools.loaded);
|
||||
const builtinLoaded = useAppSelector((s) => s.tools.builtinLoaded);
|
||||
const outputsLoaded = useAppSelector((s) => s.outputs.loaded);
|
||||
|
||||
useEffect(() => {
|
||||
if (!builtinLoaded) dispatch(fetchBuiltinTools());
|
||||
if (!toolsLoaded) dispatch(fetchTools());
|
||||
if (!outputsLoaded) dispatch(fetchOutputs());
|
||||
}, [dispatch, builtinLoaded, toolsLoaded, outputsLoaded]);
|
||||
|
||||
const { categories, itemsByCategory, allItems } = useMemo(() => {
|
||||
const cats: Unstable_MentionCategory[] = [];
|
||||
const byCategory: Record<string, Unstable_MentionItem[]> = {};
|
||||
const all: Unstable_MentionItem[] = [];
|
||||
|
||||
const addCategory = (id: string, label: string) => {
|
||||
if (!byCategory[id]) {
|
||||
cats.push({ id, label });
|
||||
byCategory[id] = [];
|
||||
}
|
||||
};
|
||||
|
||||
const addItem = (catId: string, item: Unstable_MentionItem) => {
|
||||
byCategory[catId]?.push(item);
|
||||
all.push(item);
|
||||
};
|
||||
|
||||
// --- Templates ---
|
||||
const templateValues = Object.values(templates);
|
||||
if (templateValues.length > 0) {
|
||||
addCategory('templates', 'Templates');
|
||||
for (const t of templateValues) {
|
||||
addItem(
|
||||
'templates',
|
||||
buildItem(t.id, 'template', t.name, t.description || `Template with ${t.fields.length} fields`, {
|
||||
itemType: 'template',
|
||||
command: t.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
hasFields: t.fields.length > 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Skills ---
|
||||
const skillValues = Object.values(skills);
|
||||
if (skillValues.length > 0) {
|
||||
addCategory('skills', 'Skills');
|
||||
for (const s of skillValues) {
|
||||
addItem(
|
||||
'skills',
|
||||
buildItem(s.id, 'skill', s.name, s.description || 'Skill', {
|
||||
itemType: 'skill',
|
||||
command: s.command || s.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modes ---
|
||||
const modeValues = Object.values(modesMap);
|
||||
if (modeValues.length > 0) {
|
||||
addCategory('modes', 'Modes');
|
||||
for (const m of modeValues) {
|
||||
addItem(
|
||||
'modes',
|
||||
buildItem(m.id, 'mode', m.name, m.description || 'Switch to this mode', {
|
||||
itemType: 'mode',
|
||||
command: m.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Context: File ---
|
||||
addCategory('context', 'Context');
|
||||
addItem(
|
||||
'context',
|
||||
buildItem('file', 'context', 'File', 'Attach a file or folder as context', {
|
||||
itemType: 'file',
|
||||
command: 'file',
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Actions: Web ---
|
||||
const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred);
|
||||
const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred);
|
||||
if (hasWebSearch || hasWebFetch) {
|
||||
const webTools = [hasWebSearch && 'WebSearch', hasWebFetch && 'WebFetch'].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
addItem(
|
||||
'context',
|
||||
buildItem('web', 'context', 'Web', 'Search the web and fetch URLs', {
|
||||
itemType: 'tool-group',
|
||||
command: 'web',
|
||||
toolNames: webTools,
|
||||
iconKey: 'Web',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- MCP Tool Groups ---
|
||||
for (const tool of Object.values(customTools)) {
|
||||
if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue;
|
||||
const services = tool.tool_permissions?._services as Record<string, { read: string[]; write: string[] }> | undefined;
|
||||
if (!services) continue;
|
||||
const perms = tool.tool_permissions as Record<string, any>;
|
||||
const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record<string, string[]>;
|
||||
const enabled: { name: string; tools: string[] }[] = [];
|
||||
for (const [sn, st] of Object.entries(services)) {
|
||||
const names = [...(st.read || []), ...(st.write || [])].filter((n) => perms[n] !== 'deny');
|
||||
if (names.length > 0) enabled.push({ name: sn, tools: names });
|
||||
}
|
||||
if (enabled.length === 0) continue;
|
||||
const catId = `mcp-${tool.id}`;
|
||||
addCategory(catId, tool.name);
|
||||
const emitted = new Set<string>();
|
||||
for (const [gn, gsn] of Object.entries(serviceGroups)) {
|
||||
const gc = gn.toLowerCase().replace(/\s+/g, '-');
|
||||
const gs = enabled.filter((s) => gsn.includes(s.name));
|
||||
if (gs.length === 0) continue;
|
||||
gs.forEach((s) => emitted.add(s.name));
|
||||
if (gs.length >= 2) {
|
||||
addItem(catId, buildItem(`mcp-${tool.id}-group-${gn}`, 'context', gn, `Use all ${gn} actions`, { itemType: 'tool-group', command: gc, toolNames: gs.flatMap((s) => s.tools), iconKey: gn }));
|
||||
}
|
||||
for (const svc of gs) {
|
||||
const cmd = gs.length >= 2 ? `${gc}/${svc.name.toLowerCase().replace(/\s+/g, '-')}` : svc.name.toLowerCase().replace(/\s+/g, '-');
|
||||
addItem(catId, buildItem(`mcp-${tool.id}-${svc.name}`, 'context', svc.name, `Use ${svc.name} actions from ${tool.name}`, { itemType: 'tool-group', command: cmd, toolNames: svc.tools, iconKey: gn }));
|
||||
}
|
||||
}
|
||||
for (const svc of enabled) {
|
||||
if (emitted.has(svc.name)) continue;
|
||||
addItem(catId, buildItem(`mcp-${tool.id}-${svc.name}`, 'context', svc.name, `Use ${svc.name} actions from ${tool.name}`, { itemType: 'tool-group', command: svc.name.toLowerCase().replace(/\s+/g, '-'), toolNames: svc.tools }));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Apps / Outputs ---
|
||||
const outputValues = Object.values(outputItems).filter((o) => o.permission !== 'deny');
|
||||
if (outputValues.length > 0) {
|
||||
addCategory('apps', 'Apps');
|
||||
for (const out of outputValues) {
|
||||
addItem(
|
||||
'apps',
|
||||
buildItem(`view-${out.id}`, 'context', out.name, out.description || `Render ${out.name} view`, {
|
||||
itemType: 'output',
|
||||
command: out.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
toolNames: ['RenderOutput'],
|
||||
iconKey: 'View',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { categories: cats, itemsByCategory: byCategory, allItems: all };
|
||||
}, [templates, skills, modesMap, builtinTools, customTools, outputItems]);
|
||||
|
||||
return useMemo<Unstable_MentionAdapter>(
|
||||
() => ({
|
||||
categories: () => categories,
|
||||
categoryItems: (categoryId: string) => itemsByCategory[categoryId] ?? [],
|
||||
search: (query: string) => {
|
||||
if (!query) return allItems;
|
||||
const lower = query.toLowerCase();
|
||||
return allItems.filter((item) => matchesQuery(item, lower));
|
||||
},
|
||||
}),
|
||||
[categories, itemsByCategory, allItems],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
|
||||
export interface AttachedImage {
|
||||
data: string;
|
||||
media_type: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface ForcedToolGroup {
|
||||
label: string;
|
||||
tools: string[];
|
||||
iconKey?: string;
|
||||
}
|
||||
|
||||
export interface AttachedSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function useComposerAttachments() {
|
||||
const [images, setImages] = useState<AttachedImage[]>([]);
|
||||
const [contextPaths, setContextPaths] = useState<ContextPath[]>([]);
|
||||
const [forcedTools, setForcedTools] = useState<ForcedToolGroup[]>([]);
|
||||
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const generalFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addImageFiles = useCallback((files: FileList | File[]) => {
|
||||
Array.from(files).forEach((file) => {
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
setImages((prev) => [
|
||||
...prev,
|
||||
{ data: result.split(',')[1], media_type: file.type, preview: result },
|
||||
]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const uploadAndAttachFiles = useCallback(async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((f) => formData.append('files', f));
|
||||
const resp = await fetch(`${API_BASE}/settings/upload-files`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!resp.ok) throw new Error('Upload failed');
|
||||
const data = await resp.json();
|
||||
const newPaths: ContextPath[] = (data.files || []).map((f: { path: string }) => ({
|
||||
path: f.path,
|
||||
type: 'file' as const,
|
||||
}));
|
||||
setContextPaths((prev) => [...prev, ...newPaths]);
|
||||
} catch (err) {
|
||||
console.error('File upload failed:', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback(
|
||||
(idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)),
|
||||
[],
|
||||
);
|
||||
|
||||
const removeContextPath = useCallback(
|
||||
(idx: number) => setContextPaths((prev) => prev.filter((_, i) => i !== idx)),
|
||||
[],
|
||||
);
|
||||
|
||||
const removeForcedTool = useCallback(
|
||||
(idx: number) => setForcedTools((prev) => prev.filter((_, i) => i !== idx)),
|
||||
[],
|
||||
);
|
||||
|
||||
const removeSkill = useCallback(
|
||||
(id: string) =>
|
||||
setAttachedSkills((prev) => {
|
||||
const { [id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setImages([]);
|
||||
setContextPaths([]);
|
||||
setForcedTools([]);
|
||||
setAttachedSkills({});
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
if (e.dataTransfer.files.length === 0) return;
|
||||
const allFiles = Array.from(e.dataTransfer.files);
|
||||
const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
|
||||
const otherFiles = allFiles.filter((f) => !f.type.startsWith('image/'));
|
||||
if (imageFiles.length > 0) addImageFiles(imageFiles);
|
||||
if (otherFiles.length > 0) uploadAndAttachFiles(otherFiles);
|
||||
},
|
||||
[addImageFiles, uploadAndAttachFiles],
|
||||
);
|
||||
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
const allFiles = Array.from(files);
|
||||
const imgFiles = allFiles.filter((f) => f.type.startsWith('image/'));
|
||||
const otherFiles = allFiles.filter((f) => !f.type.startsWith('image/'));
|
||||
if (imgFiles.length > 0) addImageFiles(imgFiles);
|
||||
if (otherFiles.length > 0) uploadAndAttachFiles(otherFiles);
|
||||
e.target.value = '';
|
||||
},
|
||||
[addImageFiles, uploadAndAttachFiles],
|
||||
);
|
||||
|
||||
return {
|
||||
images, contextPaths, forcedTools, attachedSkills, isUploading, isDragOver,
|
||||
generalFileInputRef,
|
||||
setImages, setContextPaths, setForcedTools, setAttachedSkills,
|
||||
addImageFiles, uploadAndAttachFiles, removeImage, removeContextPath,
|
||||
removeForcedTool, removeSkill, clearAll,
|
||||
handleDragOver, handleDragLeave, handleDrop, handleFileInputChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, type MutableRefObject } from 'react';
|
||||
import {
|
||||
useExternalStoreRuntime,
|
||||
type ThreadMessageLike,
|
||||
@@ -13,6 +13,28 @@ import {
|
||||
type StreamingMessage,
|
||||
} from '@/shared/state/agentsSlice';
|
||||
|
||||
export interface ComposerExtras {
|
||||
images?: Array<{ data: string; media_type: string }>;
|
||||
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
|
||||
forcedTools?: string[];
|
||||
attachedSkills?: Array<{ id: string; name: string; content: string }>;
|
||||
selectedBrowserIds?: string[];
|
||||
}
|
||||
|
||||
export interface DispatchableMessage {
|
||||
prompt: string;
|
||||
images?: Array<{ data: string; media_type: string }>;
|
||||
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
|
||||
forcedTools?: string[];
|
||||
attachedSkills?: Array<{ id: string; name: string; content: string }>;
|
||||
selectedBrowserIds?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeOptions {
|
||||
composerExtrasRef?: MutableRefObject<ComposerExtras>;
|
||||
dispatchMessage?: (msg: DispatchableMessage) => void;
|
||||
}
|
||||
|
||||
type RawMessage = AgentMessage | (StreamingMessage & { _streaming: true });
|
||||
|
||||
function convertMessage(msg: RawMessage): ThreadMessageLike {
|
||||
@@ -98,7 +120,10 @@ function extractText(message: AppendMessage): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function useOpenSwarmRuntime(sessionId: string | undefined) {
|
||||
export function useOpenSwarmRuntime(
|
||||
sessionId: string | undefined,
|
||||
options?: RuntimeOptions,
|
||||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
const session = useAppSelector((state) =>
|
||||
sessionId ? state.agents.sessions[sessionId] : undefined,
|
||||
@@ -120,9 +145,18 @@ export function useOpenSwarmRuntime(sessionId: string | undefined) {
|
||||
if (!sessionId) return;
|
||||
const text = extractText(message);
|
||||
if (!text) return;
|
||||
dispatch(sendMessageThunk({ sessionId, prompt: text }));
|
||||
|
||||
if (options?.dispatchMessage) {
|
||||
const extras = options.composerExtrasRef?.current ?? {};
|
||||
if (options.composerExtrasRef) {
|
||||
options.composerExtrasRef.current = {};
|
||||
}
|
||||
options.dispatchMessage({ prompt: text, ...extras });
|
||||
} else {
|
||||
dispatch(sendMessageThunk({ sessionId, prompt: text }));
|
||||
}
|
||||
},
|
||||
[sessionId, dispatch],
|
||||
[sessionId, dispatch, options],
|
||||
);
|
||||
|
||||
const onEdit = useCallback(
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user