mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
feat: enhance agent creation UI/UX, improve agent memory configuration, and refine styling across agent components.
This commit is contained in:
1 parent
08ced252b2
commit
48878848df
13 files changed
+203
-81
No files matched your search
+11
-1
@@ -489,8 +489,18 @@ func runGateway() {
|
||||
}
|
||||
}
|
||||
|
||||
// Memory detection
|
||||
// Memory detection: SQLite (standalone) or PG (managed) — either enables memory.
|
||||
hasMemory := memMgr != nil
|
||||
if !hasMemory && managedStores != nil && managedStores.Memory != nil {
|
||||
hasMemory = true
|
||||
// PG memory is available but SQLite failed or wasn't created.
|
||||
// Ensure memory tools are registered so wireManagedExtras can wire PG store to them.
|
||||
if _, exists := toolsReg.Get("memory_search"); !exists {
|
||||
toolsReg.Register(tools.NewMemorySearchTool(nil))
|
||||
toolsReg.Register(tools.NewMemoryGetTool(nil))
|
||||
slog.Info("memory tools registered for managed mode (PG-backed)")
|
||||
}
|
||||
}
|
||||
|
||||
// Wire SessionStoreAware + BusAware on tools that need them
|
||||
for _, name := range []string{"sessions_list", "session_status", "sessions_history", "sessions_send"} {
|
||||
|
||||
+60
-1
@@ -66,7 +66,23 @@ func runAutoOnboard(cfgPath string) bool {
|
||||
|
||||
fmt.Printf(" Provider: %s (model: %s)\n", provider, cfg.Agents.Defaults.Model)
|
||||
|
||||
// 2. Gateway token
|
||||
// 2. Auto-enable memory: detect embedding-capable API keys from env.
|
||||
// Embedding providers: openai, openrouter, gemini (same order as resolveEmbeddingProvider).
|
||||
embProvider := autoDetectEmbeddingProvider(cfg)
|
||||
if embProvider != "" {
|
||||
enabled := true
|
||||
cfg.Agents.Defaults.Memory = &config.MemoryConfig{
|
||||
Enabled: &enabled,
|
||||
EmbeddingProvider: embProvider,
|
||||
}
|
||||
fmt.Printf(" Memory: enabled (embedding: %s)\n", embProvider)
|
||||
} else {
|
||||
fmt.Println(" Memory: enabled (FTS-only, no embedding API key)")
|
||||
enabled := true
|
||||
cfg.Agents.Defaults.Memory = &config.MemoryConfig{Enabled: &enabled}
|
||||
}
|
||||
|
||||
// 3. Gateway token
|
||||
if cfg.Gateway.Token == "" {
|
||||
cfg.Gateway.Token = onboardGenerateToken(16)
|
||||
slog.Info("auto-onboard: generated gateway token")
|
||||
@@ -150,6 +166,36 @@ func runAutoOnboard(cfgPath string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// embeddingCapable lists providers that support text embeddings.
|
||||
// Only these three have embedding provider implementations in resolveEmbeddingProvider.
|
||||
var embeddingCapable = map[string]bool{
|
||||
"openai": true,
|
||||
"openrouter": true,
|
||||
"gemini": true,
|
||||
}
|
||||
|
||||
// autoDetectEmbeddingProvider picks an embedding provider from available API keys.
|
||||
// Priority: primary provider (GOCLAW_PROVIDER) if embedding-capable, then openai → openrouter → gemini.
|
||||
func autoDetectEmbeddingProvider(cfg *config.Config) string {
|
||||
// Prioritize the primary provider if it supports embeddings.
|
||||
primary := cfg.Agents.Defaults.Provider
|
||||
if embeddingCapable[primary] && resolveProviderAPIKey(cfg, primary) != "" {
|
||||
return primary
|
||||
}
|
||||
|
||||
// Fallback: first available embedding-capable key.
|
||||
if cfg.Providers.OpenAI.APIKey != "" {
|
||||
return "openai"
|
||||
}
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
return "openrouter"
|
||||
}
|
||||
if cfg.Providers.Gemini.APIKey != "" {
|
||||
return "gemini"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectProvider finds the first provider with an API key in the environment.
|
||||
func detectProvider(cfg *config.Config) (string, string) {
|
||||
for _, name := range providerPriority {
|
||||
@@ -234,6 +280,19 @@ func saveCleanConfig(cfgPath string, cfg *config.Config) error {
|
||||
agents["defaults"].(map[string]interface{})["subagents"] = cfg.Agents.Defaults.Subagents
|
||||
}
|
||||
|
||||
if mc := cfg.Agents.Defaults.Memory; mc != nil {
|
||||
mem := map[string]interface{}{
|
||||
"enabled": mc.Enabled == nil || *mc.Enabled,
|
||||
}
|
||||
if mc.EmbeddingProvider != "" {
|
||||
mem["embedding_provider"] = mc.EmbeddingProvider
|
||||
}
|
||||
if mc.EmbeddingModel != "" {
|
||||
mem["embedding_model"] = mc.EmbeddingModel
|
||||
}
|
||||
agents["defaults"].(map[string]interface{})["memory"] = mem
|
||||
}
|
||||
|
||||
// Build gateway section (no token — secret).
|
||||
gateway := map[string]interface{}{
|
||||
"host": cfg.Gateway.Host,
|
||||
|
||||
@@ -135,6 +135,15 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-agent memory: enabled if global memory manager exists AND
|
||||
// per-agent config doesn't explicitly disable it.
|
||||
hasMemory := deps.HasMemory
|
||||
if mc := ag.ParseMemoryConfig(); mc != nil && mc.Enabled != nil {
|
||||
if !*mc.Enabled {
|
||||
hasMemory = false
|
||||
}
|
||||
}
|
||||
|
||||
loop := NewLoop(LoopConfig{
|
||||
ID: ag.AgentKey,
|
||||
AgentUUID: ag.ID,
|
||||
@@ -150,7 +159,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
ToolPolicy: deps.ToolPolicy,
|
||||
AgentToolPolicy: ag.ParseToolsConfig(),
|
||||
SkillsLoader: deps.Skills,
|
||||
HasMemory: deps.HasMemory,
|
||||
HasMemory: hasMemory,
|
||||
ContextFiles: contextFiles,
|
||||
EnsureUserFiles: deps.EnsureUserFiles,
|
||||
ContextFileLoader: deps.ContextFileLoader,
|
||||
|
||||
@@ -122,6 +122,14 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
req.RestrictToWorkspace = true
|
||||
|
||||
// Default: enable compaction and memory for new agents
|
||||
if len(req.CompactionConfig) == 0 {
|
||||
req.CompactionConfig = json.RawMessage(`{}`)
|
||||
}
|
||||
if len(req.MemoryConfig) == 0 {
|
||||
req.MemoryConfig = json.RawMessage(`{"enabled":true}`)
|
||||
}
|
||||
|
||||
// Check if predefined agent has a description for LLM summoning
|
||||
description := extractDescription(req.OtherConfig)
|
||||
if req.AgentType == "predefined" && description != "" && h.summoner != nil {
|
||||
|
||||
@@ -51,13 +51,15 @@ function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
overlayTransparent = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
overlayTransparent?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogOverlay style={overlayTransparent ? { background: "rgba(0,0,0,0.15)" } : undefined} />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Convert any string to a valid slug: lowercase, [a-z0-9-], no leading/trailing dashes.
|
||||
* Handles Vietnamese and other diacritical characters by stripping accents first.
|
||||
*/
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/đ/g, "d")
|
||||
.replace(/Đ/g, "d")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AgentCard({ agent, onClick }: AgentCardProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-all hover:border-primary/30 hover:shadow-md"
|
||||
className="flex cursor-pointer flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-all hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
{/* Top row: icon + name + status */}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -33,6 +33,7 @@ interface AgentCreateDialogProps {
|
||||
export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateDialogProps) {
|
||||
const { providers } = useProviders();
|
||||
const [agentKey, setAgentKey] = useState("");
|
||||
const [keyTouched, setKeyTouched] = useState(false);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
@@ -63,6 +64,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
});
|
||||
onOpenChange(false);
|
||||
setAgentKey("");
|
||||
setKeyTouched(false);
|
||||
setDisplayName("");
|
||||
setProvider("");
|
||||
setModel("");
|
||||
@@ -82,33 +84,44 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agentKey">Agent Key *</Label>
|
||||
<Input
|
||||
id="agentKey"
|
||||
value={agentKey}
|
||||
onChange={(e) => setAgentKey(slugify(e.target.value))}
|
||||
placeholder="e.g. my-agent"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Agent"
|
||||
/>
|
||||
<div className="space-y-4 py-4 overflow-y-auto min-h-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name *</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (!keyTouched && displayName.trim()) {
|
||||
setAgentKey(slugify(displayName.trim()));
|
||||
}
|
||||
}}
|
||||
placeholder="My Agent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agentKey">Agent Key *</Label>
|
||||
<Input
|
||||
id="agentKey"
|
||||
value={agentKey}
|
||||
onChange={(e) => {
|
||||
setKeyTouched(true);
|
||||
setAgentKey(e.target.value);
|
||||
}}
|
||||
onBlur={() => setAgentKey(slugify(agentKey))}
|
||||
placeholder="e.g. my-agent"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Lowercase, numbers, hyphens</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Label>Provider *</Label>
|
||||
{enabledProviders.length > 0 ? (
|
||||
<Select value={provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger>
|
||||
@@ -131,38 +144,58 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Label>Model *</Label>
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
|
||||
/>
|
||||
{provider && !modelsLoading && models.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">This provider doesn't list models — type the model ID manually.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Agent Type</Label>
|
||||
<Select value={agentType} onValueChange={(v) => setAgentType(v as "open" | "predefined")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open (per-user context)</SelectItem>
|
||||
<SelectItem value="predefined">Predefined (agent-level)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentType("open")}
|
||||
className={`flex-1 rounded-md border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
agentType === "open"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input bg-background hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
Open
|
||||
<span className="block text-xs font-normal opacity-70">Per-user context</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentType("predefined")}
|
||||
className={`flex-1 rounded-md border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
agentType === "predefined"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input bg-background hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
Predefined
|
||||
<span className="block text-xs font-normal opacity-70">Agent-level config</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agentType === "predefined" && (
|
||||
<div className="space-y-3">
|
||||
<Label>Describe Your Agent</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{AGENT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => setDescription(preset.prompt)}
|
||||
className="rounded-full border px-3 py-1 text-xs transition-colors hover:bg-accent"
|
||||
className="rounded-full border px-2.5 py-0.5 text-xs transition-colors hover:bg-accent"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
@@ -185,7 +218,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!agentKey.trim() || !isValidSlug(agentKey) || loading}>
|
||||
<Button onClick={handleCreate} disabled={!displayName.trim() || !agentKey.trim() || !isValidSlug(agentKey) || !provider.trim() || !model.trim() || loading}>
|
||||
{loading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -149,8 +149,8 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
<Check className="h-3.5 w-3.5" /> Saved
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-1.5">
|
||||
<Save className="h-4 w-4" />
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{!saving && <Save className="h-4 w-4" />}
|
||||
{saving ? "Saving..." : "Save Config"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ArrowLeft, Bot, Star } from "lucide-react";
|
||||
import { useAgentDetail } from "../hooks/use-agent-detail";
|
||||
import { AgentGeneralTab } from "./agent-general-tab";
|
||||
@@ -88,40 +87,38 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="config">Config</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger value="shares">Shares</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="config">Config</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger value="shares">Shares</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="mt-4">
|
||||
<AgentGeneralTab agent={agent} onUpdate={updateAgent} />
|
||||
</TabsContent>
|
||||
<TabsContent value="general" className="mt-4">
|
||||
<AgentGeneralTab agent={agent} onUpdate={updateAgent} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="config" className="mt-4">
|
||||
<AgentConfigTab agent={agent} onUpdate={updateAgent} />
|
||||
</TabsContent>
|
||||
<TabsContent value="config" className="mt-4">
|
||||
<AgentConfigTab agent={agent} onUpdate={updateAgent} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-4">
|
||||
<AgentFilesTab
|
||||
agent={agent}
|
||||
files={files}
|
||||
onGetFile={getFile}
|
||||
onSetFile={setFile}
|
||||
onRegenerate={regenerateAgent}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-4">
|
||||
<AgentFilesTab
|
||||
agent={agent}
|
||||
files={files}
|
||||
onGetFile={getFile}
|
||||
onSetFile={setFile}
|
||||
onRegenerate={regenerateAgent}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shares" className="mt-4">
|
||||
<AgentSharesTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="shares" className="mt-4">
|
||||
<AgentSharesTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -199,9 +199,8 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="gap-1"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{!saving && <Save className="h-3.5 w-3.5" />}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -238,8 +238,8 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
<Check className="h-3.5 w-3.5" /> Saved
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-1.5">
|
||||
<Save className="h-4 w-4" />
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{!saving && <Save className="h-4 w-4" />}
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SummoningModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => { if (status === "summoning") e.preventDefault(); }}>
|
||||
<DialogContent className="sm:max-w-md" overlayTransparent onInteractOutside={(e) => { if (status === "summoning") e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">
|
||||
{status === "completed"
|
||||
@@ -126,7 +126,7 @@ export function SummoningModal({
|
||||
</div>
|
||||
|
||||
{/* Agent name */}
|
||||
<p className="text-sm text-foreground/80">
|
||||
<p className="text-sm text-foreground">
|
||||
{status === "completed" ? (
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{agentName} is ready!
|
||||
@@ -148,8 +148,8 @@ export function SummoningModal({
|
||||
return (
|
||||
<motion.div
|
||||
key={file.name}
|
||||
initial={{ opacity: 0.6 }}
|
||||
animate={{ opacity: done ? 1 : 0.6 }}
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-1.5"
|
||||
>
|
||||
@@ -165,7 +165,7 @@ export function SummoningModal({
|
||||
{done ? "\u2713" : i + 1}
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<span className={`text-sm ${done ? "font-medium" : ""}`}>
|
||||
<span className={`text-sm text-foreground ${done ? "font-medium" : ""}`}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{file.label}</span>
|
||||
|
||||
Reference in new issue
Block a user