Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee85556584 | ||
|
|
801a684c26 | ||
|
|
9c44d6b497 | ||
|
|
b020ab5dab | ||
|
|
53d1f04607 | ||
|
|
ab38738747 |
+11
@@ -4,6 +4,7 @@ import { auth } from './auth'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo,
|
||||
ActivityCard,
|
||||
} from './contracts'
|
||||
|
||||
const BASE = ''
|
||||
@@ -43,6 +44,16 @@ export const api = {
|
||||
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
|
||||
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
|
||||
|
||||
listCards: (conversationId: string) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
||||
cardAction: (cardId: string, actionId: string, freeText?: string) =>
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) })
|
||||
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
|
||||
cardPin: (cardId: string, pinned: boolean) =>
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
|
||||
cardDismiss: (cardId: string) =>
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)),
|
||||
|
||||
upload: (projectId: string, file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
|
||||
import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer } from '@ui'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
|
||||
marked.setOptions({ breaks: true })
|
||||
@@ -34,8 +34,16 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
return <div className="tool-chip">❓ {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
||||
case 'plan':
|
||||
return null // 计划在右栏 Plan 标签呈现
|
||||
case 'card':
|
||||
return <div className="ui-section"><UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} /></div>
|
||||
case 'card': {
|
||||
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
|
||||
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
|
||||
return (
|
||||
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
|
||||
onClick={() => m && store.openInbox(m[1])}>
|
||||
<UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'error':
|
||||
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
|
||||
default:
|
||||
@@ -161,18 +169,19 @@ function HITLCardView({ card }: { card: HITLCard }) {
|
||||
}
|
||||
|
||||
function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) => void }) {
|
||||
const [commands, setCommands] = useState<{ name: string; description: string }[]>([])
|
||||
const [commands, setCommands] = useState<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>([])
|
||||
const [active, setActive] = useState(0)
|
||||
useEffect(() => { api.listCommands().then(setCommands).catch(() => undefined) }, [])
|
||||
const q = text.slice(1).toLowerCase()
|
||||
const hits = commands.filter(c => c.name.slice(1).toLowerCase().startsWith(q))
|
||||
// 四入口同源(D27):菜单即注册表——有 `/` 别名的可直接补全;无别名的展示说明(对称提示)。
|
||||
const hits = commands.filter(c => (c.name ?? '').slice(1).toLowerCase().startsWith(q))
|
||||
if (hits.length === 0) return null
|
||||
return (
|
||||
<div className="cmd-menu">
|
||||
{hits.map((c, i) => (
|
||||
<div key={c.name} className={`cmd-item ${i === active ? 'active' : ''}`}
|
||||
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name + ' ')}>
|
||||
<span className="name">{c.name}</span><span>{c.description}</span>
|
||||
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name! + ' ')}>
|
||||
<span className="name">{c.name}{c.danger === 'high' ? ' ⚠' : ''}</span><span>{c.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -189,6 +198,7 @@ export function Conversation() {
|
||||
const connected = useStore(s => s.connected)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const profiles = useStore(s => s.profiles)
|
||||
const cards = useStore(s => s.cards)
|
||||
|
||||
const [text, setText] = useState('')
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||
@@ -238,13 +248,30 @@ export function Conversation() {
|
||||
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>■ 停止</button>}
|
||||
</div>
|
||||
|
||||
<div className="chat-body" ref={bodyRef}>
|
||||
<div className="chat-body" ref={bodyRef}
|
||||
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('drag-over') }}
|
||||
onDragLeave={e => e.currentTarget.classList.remove('drag-over')}
|
||||
onDrop={async e => {
|
||||
e.preventDefault(); e.currentTarget.classList.remove('drag-over')
|
||||
for (const f of Array.from(e.dataTransfer.files ?? [])) {
|
||||
const id = await store.uploadAttachment(f)
|
||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
||||
}
|
||||
}}>
|
||||
{messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => {
|
||||
if (m.role === 'user') {
|
||||
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
|
||||
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
|
||||
return (
|
||||
<div className="msg user" key={m.id}>
|
||||
<div className="msg-role">你</div>
|
||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||
{anchored.map(c => (
|
||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||
onLocate: card => store.openInbox(card.id),
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -276,7 +303,7 @@ export function Conversation() {
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
<textarea rows={1} value={text} placeholder={artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||||
{active ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 右栏工作台:Plan / Todos / Artifacts / Agents + 插件面板(4.4);tab 显隐排序见设置。
|
||||
// 右栏工作台:收件箱(D25 置首)+ Plan / Todos / Artifacts / Agents + 插件面板;tab 显隐排序见设置。
|
||||
import { store, useStore, isTerminal } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { UIRenderer, type DocBridge } from '@ui'
|
||||
import { UIRenderer, InboxList, pendingCount, type DocBridge, type ActivityCard } from '@ui'
|
||||
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
export const docBridge: DocBridge = {
|
||||
@@ -19,8 +19,11 @@ export function Workbench() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const runs = useStore(s => s.runs)
|
||||
const pluginPanels = useStore(s => s.pluginPanels)
|
||||
const cards = useStore(s => s.cards)
|
||||
const pending = pendingCount(cards)
|
||||
|
||||
const tabs: { id: string; label: string }[] = [
|
||||
{ id: 'inbox', label: pending > 0 ? `收件箱 ${pending}` : '收件箱' },
|
||||
{ id: 'plan', label: '计划' },
|
||||
{ id: 'todos', label: '待办' },
|
||||
{ id: 'artifacts', label: `工件${artifacts.length ? ` ${artifacts.length}` : ''}` },
|
||||
@@ -38,6 +41,7 @@ export function Workbench() {
|
||||
<button className="tab" title="收起工作台" onClick={store.toggleRight}>»</button>
|
||||
</div>
|
||||
<div className="tab-body">
|
||||
{ui.rightTab === 'inbox' && <InboxTab cards={cards} />}
|
||||
{ui.rightTab === 'plan' && <PlanTab plan={plan} />}
|
||||
{ui.rightTab === 'todos' && <TodosTab todos={todos} />}
|
||||
{ui.rightTab === 'artifacts' && <ArtifactsTab />}
|
||||
@@ -50,6 +54,14 @@ export function Workbench() {
|
||||
)
|
||||
}
|
||||
|
||||
function InboxTab({ cards }: { cards: ActivityCard[] }) {
|
||||
return <InboxList cards={cards} callbacks={{
|
||||
onAction: (c, id) => store.cardAction(c, id),
|
||||
onPin: (c, p) => store.cardPin(c, p),
|
||||
onDismiss: c => store.cardDismiss(c),
|
||||
}} />
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status?: string }) {
|
||||
if (status === 'done' || status === 'completed') return <span style={{ color: 'var(--ok)' }}>●</span>
|
||||
if (status === 'in_progress' || status === 'running') return <span style={{ color: 'var(--accent)' }}>◐</span>
|
||||
|
||||
+52
-2
@@ -81,7 +81,8 @@ export type StreamEventType =
|
||||
| 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
|
||||
| 'agent.spawn' | 'agent.progress' | 'agent.result'
|
||||
| 'artifact.created' | 'artifact.updated'
|
||||
| 'run.completed' | 'run.failed' | 'error';
|
||||
| 'run.completed' | 'run.failed' | 'error'
|
||||
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
|
||||
|
||||
export interface StreamEvent<P = unknown> {
|
||||
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
|
||||
@@ -106,6 +107,28 @@ export interface ContextObservation {
|
||||
}
|
||||
export interface RunFailedPayload { code: string; message: string }
|
||||
|
||||
// ---- 收件箱卡片 ----
|
||||
|
||||
export type CardKind =
|
||||
| 'progress' | 'confirm' | 'compare' | 'review-list'
|
||||
| 'result' | 'error' | 'status';
|
||||
|
||||
export type CardStatus =
|
||||
| 'active' | 'action_required' | 'done'
|
||||
| 'resolved' | 'failed' | 'dismissed';
|
||||
|
||||
export interface ActivityCard {
|
||||
id: ID; projectId: ID; conversationId?: ID;
|
||||
sourceRunId?: ID; anchorMessageId?: ID;
|
||||
kind: CardKind; title: string; status: CardStatus; tone?: string;
|
||||
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
|
||||
pinned?: boolean; createdAt: string; updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CardPayload { card: ActivityCard }
|
||||
export interface CardStatusPayload { cardId: ID; status: CardStatus; tone?: string; note?: string }
|
||||
export interface CardResolvedPayload { cardId: ID; actionId?: string; answer?: string }
|
||||
|
||||
// ---- HITL ----
|
||||
|
||||
export type HITLKind = 'choice' | 'input' | 'confirm' | 'form';
|
||||
@@ -118,6 +141,7 @@ export interface HITLCard {
|
||||
id: ID; runId: ID; conversationId: ID; question: string; kind: HITLKind;
|
||||
choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string;
|
||||
timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean;
|
||||
anchorMessageId?: ID; blocked?: boolean; blockReason?: string;
|
||||
}
|
||||
|
||||
export interface HITLResponse {
|
||||
@@ -127,7 +151,8 @@ export interface HITLResponse {
|
||||
// ---- UI 词汇 ----
|
||||
|
||||
export type UINodeType =
|
||||
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite';
|
||||
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
|
||||
| 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
|
||||
|
||||
export interface UINode {
|
||||
type: UINodeType;
|
||||
@@ -140,6 +165,25 @@ export interface UINode {
|
||||
actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger' }[];
|
||||
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
|
||||
children?: UINode[];
|
||||
|
||||
progress?: { label?: string; value: number; tone?: string };
|
||||
compare?: {
|
||||
left: { title?: string; tone?: string; nodes?: UINode[] };
|
||||
right: { title?: string; tone?: string; nodes?: UINode[] };
|
||||
};
|
||||
tree?: {
|
||||
nodes: {
|
||||
id: string; label: string; tone?: string; badge?: string; count?: number;
|
||||
selected?: boolean; children?: unknown[];
|
||||
}[];
|
||||
};
|
||||
table?: {
|
||||
columns: { key: string; label: string }[];
|
||||
rows: { id?: string; cells: [string, string][]; actions?: UINode['actions'] }[];
|
||||
};
|
||||
steps?: { current: number; steps: { label: string; status?: 'pending' | 'active' | 'done' | 'failed'; tone?: string }[] };
|
||||
tabs?: { active?: string; tabs: { id: string; label: string; nodes?: UINode[] }[] };
|
||||
timeline?: { items: { time?: string; title: string; tone?: string; markdown?: string; ref?: string }[] };
|
||||
}
|
||||
|
||||
export interface UIField {
|
||||
@@ -179,6 +223,7 @@ export interface PluginManifest {
|
||||
provides: {
|
||||
tools?: string[]; workflows?: string[]; intents?: string[];
|
||||
prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[];
|
||||
commands?: string[]; workspaces?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +231,11 @@ export interface PluginPanel {
|
||||
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
|
||||
}
|
||||
|
||||
export interface PluginWorkspace {
|
||||
id: string; title: string; icon?: string; shortcut?: string;
|
||||
badgeTool?: string; body: UINode[]; refreshTool?: string;
|
||||
}
|
||||
|
||||
// ---- 摄取 / 检索 ----
|
||||
|
||||
export interface ParsedDocument {
|
||||
|
||||
+65
-4
@@ -4,7 +4,7 @@ import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||||
AgentProfile, StreamEvent, PluginPanel,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||||
} from './contracts'
|
||||
|
||||
export interface ContextObs {
|
||||
@@ -50,6 +50,7 @@ interface AppState {
|
||||
plan: PlanSnapshot | null
|
||||
todos: TodoList | null
|
||||
artifacts: Artifact[]
|
||||
cards: ActivityCard[]
|
||||
profiles: AgentProfile[]
|
||||
pluginPanels: PluginPanel[]
|
||||
ui: UIState
|
||||
@@ -63,7 +64,7 @@ const DEFAULT_UI: UIState = {
|
||||
leftCollapsed: false,
|
||||
rightWidth: 340,
|
||||
rightCollapsed: false,
|
||||
rightTab: 'plan',
|
||||
rightTab: 'inbox',
|
||||
theme: 'dark',
|
||||
autoCollapseProcess: true,
|
||||
settingsOpen: false,
|
||||
@@ -83,7 +84,7 @@ let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], profiles: [], pluginPanels: [],
|
||||
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
|
||||
ui: loadUI(), connected: false, error: null, toast: null,
|
||||
}
|
||||
|
||||
@@ -151,7 +152,7 @@ export const store = {
|
||||
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null })
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] })
|
||||
await store.reconcile()
|
||||
await store.connectLive()
|
||||
},
|
||||
@@ -172,6 +173,7 @@ export const store = {
|
||||
})
|
||||
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
|
||||
set({ plan, todos })
|
||||
store.refreshCards(d.conversation.id)
|
||||
} catch (e) {
|
||||
set({ error: String(e) })
|
||||
}
|
||||
@@ -252,6 +254,24 @@ export const store = {
|
||||
set({ todos: p.list })
|
||||
break
|
||||
}
|
||||
case 'card.created':
|
||||
case 'card.updated': {
|
||||
const p = e.payload as { card: ActivityCard }
|
||||
if (p?.card) store.upsertCard(p.card)
|
||||
break
|
||||
}
|
||||
case 'card.status': {
|
||||
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
|
||||
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)
|
||||
set({ cards })
|
||||
break
|
||||
}
|
||||
case 'card.resolved': {
|
||||
const p = e.payload as { cardId: string }
|
||||
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)
|
||||
set({ cards })
|
||||
break
|
||||
}
|
||||
case 'artifact.created': {
|
||||
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
|
||||
break
|
||||
@@ -319,6 +339,41 @@ export const store = {
|
||||
}
|
||||
},
|
||||
|
||||
upsertCard(card: ActivityCard) {
|
||||
const i = state.cards.findIndex(c => c.id === card.id)
|
||||
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
|
||||
set({ cards })
|
||||
},
|
||||
|
||||
async refreshCards(convId: string) {
|
||||
try { set({ cards: await api.listCards(convId) }) } catch { /* 会话竞态,静默 */ }
|
||||
},
|
||||
|
||||
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
|
||||
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
|
||||
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
|
||||
const hitlRef = card.body?.find(n => n.type === 'kv')?.kv?.find(([k]) => k === '来源')?.[1]
|
||||
if (hitlRef?.startsWith('hitl:')) {
|
||||
await store.resolveHITL(hitlRef.slice(5), actionId ? { choiceId: actionId } : { freeText })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const out = await api.cardAction(card.id, actionId, freeText)
|
||||
store.upsertCard(out.card)
|
||||
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
|
||||
} catch (e) {
|
||||
store.toast(`操作失败:${e}`)
|
||||
}
|
||||
},
|
||||
|
||||
async cardPin(card: ActivityCard, pinned: boolean) {
|
||||
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
|
||||
},
|
||||
|
||||
async cardDismiss(card: ActivityCard) {
|
||||
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
|
||||
},
|
||||
|
||||
async refreshArtifacts(projectId: string) {
|
||||
try {
|
||||
const artifacts = await api.listArtifacts(projectId)
|
||||
@@ -354,6 +409,12 @@ export const store = {
|
||||
setUI,
|
||||
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
|
||||
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
||||
if (cardId) setTimeout(() => {
|
||||
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 60)
|
||||
},
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
|
||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||
|
||||
Reference in New Issue
Block a user