Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/components/WidgetEmbed/WidgetEmbedChat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ async function submit(input: ChatInput) {
transient.value = ''
}
if (name === 'handoff' || name === 'closed') {
if (name === 'handoff' && data.reason !== 'ai_error' && !items.value.some(item => item.id === data.noticeItemId)) {
if (name === 'handoff' && data.noticeItemId && data.reason !== 'ai_error' && !items.value.some(item => item.id === data.noticeItemId)) {
// The event confirms persistence; render it before the history fetch.
// Reusing its ID lets history update the same bubble without a gap.
items.value.push({
Expand All @@ -339,6 +339,12 @@ async function submit(input: ChatInput) {
clearTimeout(timeout)
scrollToBottom()
}
if (name === 'superseded') {
transient.value = ''
failure.value = ''
finished = true
clearTimeout(timeout)
}
if (name === 'handoff_unavailable' || (name === 'error' && data.code === 'support_unavailable')) {
runs.value = runs.value.map(run => run.status === 'running' ? { ...run, status: 'failed', error: 'support_unavailable' } : run)
finished = true
Expand Down Expand Up @@ -532,7 +538,7 @@ async function closeArticle() {
</div>
</template>
<div v-if="transient" class="max-w-[82%] rounded-lg rounded-bl-sm border border-border bg-card px-3 py-2.5 text-[13.5px] leading-normal"><WidgetEmbedMarkdown :text="transient" /></div>
<div v-else-if="handlingMode === 'ai' && busy && !awaitingRun" role="status" :aria-label="t('widget.thinking')" class="flex justify-start">
<div v-else-if="handlingMode === 'ai' && latestRun?.status === 'running' && !awaitingRun" role="status" :aria-label="t('widget.thinking')" class="flex justify-start">
<div class="px-3.5 py-2.5 rounded-lg rounded-bl-sm bg-card border border-border text-xs text-muted-foreground flex items-center gap-1.5">
<span class="flex items-center gap-1" aria-hidden="true">
<span v-for="n in 3" :key="n" class="w-1 h-1 rounded-full bg-current opacity-40 typing-dot" :style="{ animationDelay: `${(n - 1) * 0.16}s` }" />
Expand Down
26 changes: 21 additions & 5 deletions app/pages/dashboard/inbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const selected = ref<string | null>(null)
const detail = ref<InboxDetail | null>(null)
function showSystemNotice(part: Content['parts'][number]) {
return part.type === 'system_event' && ((part.kind === 'handoff' && part.reason !== 'ai_error')
|| (part.reason === 'manual' && (part.kind === 'snoozed' || part.kind === 'closed')))
|| (part.reason === 'manual' && ['takeover', 'snoozed', 'closed'].includes(part.kind)))
}
function handoffNotice(part: SystemEventPart) {
const builtin = part.rule?.id
Expand Down Expand Up @@ -95,6 +95,14 @@ let pollingBusy = false
const date = (value: string | null) => (value ? new Date(value).toLocaleString(locale.value) : '')
const canReply = computed(() => !!detail.value && ['open', 'pending'].includes(detail.value.conversation.status))
const isAiHandling = computed(() => detail.value?.conversation.status === 'ai_handling')
const takeoverPending = computed(() => isAiHandling.value && !!detail.value?.conversation.takeoverPending)
const takeoverFocusId = ref<string | null>(null)
watch(canReply, async (available) => {
if (!available || takeoverFocusId.value !== selected.value) return
takeoverFocusId.value = null
await nextTick()
replyInput.value?.focus()
})
const canClose = computed(() => !!detail.value && detail.value.conversation.status !== 'closed')
const statusHint = computed(() => {
const conversation = detail.value?.conversation
Expand Down Expand Up @@ -176,12 +184,14 @@ async function loadFeedback(more = false) {
feedbackTotal.value = data.pagination.total
}
function clearSelection() {
takeoverFocusId.value = null
selected.value = null
detail.value = null
detailVersion++
}

async function open(id: string) {
if (id !== selected.value) takeoverFocusId.value = null
detailVersion++
selected.value = id
detail.value = null
Expand Down Expand Up @@ -243,6 +253,10 @@ async function executeCommand(action: string, extra: Record<string, unknown> = {
const row = rows.value.find((row) => row.id === id)
if (row) Object.assign(row, result.conversation)
if (id === selected.value && detail.value) detail.value.conversation = result.conversation
if (action === 'takeover' && id === selected.value && version === detailVersion) {
if (result.conversation.takeoverPending) takeoverFocusId.value = id
else { await nextTick(); replyInput.value?.focus() }
}
if (action === 'reply') {
drafts[id] = ''
draftImages[id] = []
Expand Down Expand Up @@ -526,6 +540,7 @@ loading.value = false
<p class="mt-2 max-w-prose whitespace-normal break-words text-left">{{ locale === 'zh' && part.rule.scenarioZh ? part.rule.scenarioZh : part.rule.scenario }}</p>
</details>
<span v-else-if="part.kind === 'handoff'">{{ handoffNotice(part) }}</span>
<span v-else-if="part.kind === 'takeover'">{{ t(item.author?.id && item.author.id === session?.user.id ? 'inbox.noticeTakeoverSelf' : 'inbox.noticeTakeover', { name: item.author?.name || t('widget.messageStaff') }) }}</span>
<span v-else>{{ t(part.kind === 'snoozed' ? 'inbox.noticeSnoozed' : 'inbox.noticeClosed', { name: item.author?.name || t('widget.messageStaff'), time: date(part.dueAt ?? null) }) }}</span>
<time :datetime="item.createdAt" class="text-[10px] opacity-75">{{ time(item.createdAt) }}</time>
</div>
Expand Down Expand Up @@ -563,10 +578,11 @@ loading.value = false
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" :disabled="!canReply || sending || uploading" @change="pickFiles">
<Button type="button" variant="ghost" size="icon" class="hover:bg-secondary/50 hover:text-foreground dark:hover:bg-secondary/50 size-8 text-muted-foreground" :disabled="!canReply || sending || uploading" :title="t('widget.attachImage')" :aria-label="t('widget.attachImage')" @click="fileInput?.click()"><Icon name="lucide:image" size="16" /></Button>
<div class="flex items-center gap-2">
<span class="hidden whitespace-nowrap text-[10px] text-muted-foreground lg:inline">Enter</span>
<div class="inline-flex w-56 shrink-0 items-stretch rounded-md" :class="!canReply || sending || uploading || !sendable ? 'opacity-50' : ''" data-inbox-send>
<Button type="submit" variant="default" size="sm" class="h-9 min-w-0 flex-1 gap-1.5 rounded-r-none text-xs disabled:opacity-100" :disabled="!canReply || sending || uploading || !sendable"><Icon :name="sending ? 'lucide:loader-2' : 'lucide:send'" size="14" :class="sending ? 'animate-spin' : ''" />{{ t(deliveryMode === 'immediate' ? 'inbox.deliveryImmediate' : 'inbox.deliveryAuto') }}</Button>
<DropdownMenu>
<span v-if="canReply" class="hidden whitespace-nowrap text-[10px] text-muted-foreground lg:inline">Enter</span>
<div class="inline-flex w-56 shrink-0 items-stretch rounded-md" :class="canReply && (sending || uploading || !sendable) ? 'opacity-50' : ''" data-inbox-send>
<Button v-if="isAiHandling" type="button" variant="default" size="sm" class="h-9 min-w-0 flex-1 gap-1.5 text-xs" :disabled="busy || takeoverPending" :aria-busy="activeAction === 'takeover' || takeoverPending" @click="command('takeover')"><Icon :name="activeAction === 'takeover' || takeoverPending ? 'lucide:loader-2' : 'lucide:message-square-reply'" size="14" :class="activeAction === 'takeover' || takeoverPending ? 'animate-spin' : ''" />{{ t(takeoverPending ? 'inbox.takeoverWaiting' : 'inbox.takeover') }}</Button>
<Button v-else type="submit" variant="default" size="sm" class="h-9 min-w-0 flex-1 gap-1.5 rounded-r-none text-xs disabled:opacity-100" :disabled="!canReply || sending || uploading || !sendable"><Icon :name="sending ? 'lucide:loader-2' : 'lucide:send'" size="14" :class="sending ? 'animate-spin' : ''" />{{ t(deliveryMode === 'immediate' ? 'inbox.deliveryImmediate' : 'inbox.deliveryAuto') }}</Button>
<DropdownMenu v-if="canReply">
<DropdownMenuTrigger as-child>
<Button type="button" variant="default" size="sm" class="h-9 w-8 rounded-l-none border-l border-primary-foreground/25 px-0 disabled:opacity-100" :disabled="!canReply || sending || uploading" :title="t('inbox.deliveryMode')" :aria-label="t('inbox.deliveryMode')"><Icon name="lucide:chevron-down" size="14" /></Button>
</DropdownMenuTrigger>
Expand Down
29 changes: 29 additions & 0 deletions docs/widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@ the selected closing behavior and visitor preferences remain unchanged.
The preview supports dragging, opening, closing and resetting the launcher. It
uses the current draft and does not create conversations or save visitor state.

## Human takeover in Inbox

In **Inbox**, filter by **AI handling** or **All**, open a conversation and click
**Take over and reply** in place of the send button. The conversation becomes
**Open** immediately when AI is idle. If AI is replying, the request is saved
and the button shows **Waiting for AI to finish replying**. The current reply
finishes and is saved in full before the conversation becomes **Open** and the
staff reply field is enabled. AI failure or its existing timeout also completes
the takeover; refreshing or leaving the dashboard does not lose the request.
This is a shared inbox action; it does not assign an exclusive owner.
The send button replaces the takeover button once human replies are available.
Takeover records stay in the staff timeline; the customer sees the completed AI
answer followed by actual staff replies, with no extra takeover message.

Staff keep using the existing reply, snooze and close actions. There is no
return-to-AI action. A new customer message in a closed conversation starts a
new AI round in that same conversation, with recent customer and staff history.
History remains bounded to the existing memory window and size budget; a new
conversation does not read another conversation's messages.

`POST /api/admin/inbox/conversations/{id}/actions` accepts
`{ action: 'takeover', requestId: UUID, expectedLastSeq: number }` with the
existing `feedlog:inbox` permission, idempotency and revision checks. Only
`ai_handling` conversations can be taken over. No database migration is required.
Admin conversation responses include `takeoverPending`, derived from the saved
request and active run. Pending requests are internal and do not announce a human
before the current AI round ends. Explicitly closing a conversation still cancels
its run and invalidates any waiting takeover for that round.

## Configuration API

`GET /api/widget/config` is public and returns:
Expand Down
6 changes: 5 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,11 @@
"emailFailed": "Email failed to send. Please retry.",
"retryEmail": "Resend once",
"replyPlaceholder": "Write a reply…",
"aiReplyUnavailable": "AI is handling this conversation. Staff replies are currently unavailable.",
"aiReplyUnavailable": "AI is handling this conversation. Take over to reply personally.",
"takeover": "Take over and reply",
"takeoverWaiting": "Waiting for AI to finish replying",
"noticeTakeover": "{name} took over this conversation.",
"noticeTakeoverSelf": "You took over this conversation.",
"confirmClose": "Close this conversation? A new customer message will start a new AI round.",
"cancel": "Cancel",
"forbidden": "You do not have permission to access Inbox.",
Expand Down
6 changes: 5 additions & 1 deletion i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,11 @@
"emailFailed": "邮件发送失败,请重试。",
"retryEmail": "重新发送一次",
"replyPlaceholder": "输入回复…",
"aiReplyUnavailable": "AI 正在处理,暂不可人工回复。",
"aiReplyUnavailable": "AI 正在接待,你可以接管后回复用户。",
"takeover": "接管并回复",
"takeoverWaiting": "等待 AI 回复完成后接管",
"noticeTakeover": "{name} 接管了此会话。",
"noticeTakeoverSelf": "你接管了此会话。",
"confirmClose": "确认关闭?用户再次发来新消息时,将由 AI 开始新一轮。",
"cancel": "取消",
"forbidden": "你没有 Inbox 访问权限。",
Expand Down
Loading
Loading