feat(web): add session-type-aware UI for chat interface

- Make IM/heartbeat/schedule/subagent sessions read-only (hide input box)
- Render heartbeat user messages as info blocks with trigger metadata and
  link to heartbeat logs
- Render schedule user messages as info blocks with task metadata and
  link to schedule settings
- Render subagent user messages as full-width markdown boxes
- Add clickable spawn task results to navigate to subagent sessions
This commit is contained in:
Acbox
2026-03-31 18:09:22 +08:00
parent c0490c9688
commit c3b2ede0ce
8 changed files with 373 additions and 33 deletions
+12 -1
View File
@@ -191,7 +191,18 @@
"sessionTypeChat": "Chat",
"sessionFilterAll": "All",
"sessionSourcePrefix": "From:",
"searchSessionPlaceholder": "Search"
"searchSessionPlaceholder": "Search",
"heartbeatTrigger": "Heartbeat Trigger",
"heartbeatTime": "Time",
"heartbeatInterval": "Interval",
"heartbeatLastAt": "Last Heartbeat",
"viewHeartbeatLogs": "View Logs",
"scheduleTrigger": "Scheduled Task Trigger",
"scheduleName": "Task Name",
"scheduleDescription": "Description",
"schedulePattern": "Cron Pattern",
"scheduleMaxCalls": "Max Calls",
"viewSchedule": "View Schedule"
},
"models": {
"title": "Models",
+12 -1
View File
@@ -187,7 +187,18 @@
"sessionTypeChat": "对话",
"sessionFilterAll": "全部",
"sessionSourcePrefix": "来自:",
"searchSessionPlaceholder": "搜索"
"searchSessionPlaceholder": "搜索",
"heartbeatTrigger": "心跳触发",
"heartbeatTime": "触发时间",
"heartbeatInterval": "间隔",
"heartbeatLastAt": "上次心跳",
"viewHeartbeatLogs": "查看日志",
"scheduleTrigger": "定时任务触发",
"scheduleName": "任务名称",
"scheduleDescription": "描述",
"schedulePattern": "Cron 表达式",
"scheduleMaxCalls": "最大调用次数",
"viewSchedule": "查看定时任务"
},
"models": {
"title": "模型",
@@ -50,6 +50,8 @@
v-for="msg in messages"
:key="msg.id"
:message="msg"
:session-type="activeSession?.type"
:bot-id="currentBotId"
:on-open-media="galleryOpenBySrc"
/>
</div>
@@ -64,8 +66,11 @@
@update:open-index="gallerySetOpenIndex"
/>
<!-- Input -->
<div class="px-3 sm:px-5 lg:px-8 py-2.5">
<!-- Input (hidden for read-only sessions) -->
<div
v-if="!activeChatReadOnly"
class="px-3 sm:px-5 lg:px-8 py-2.5"
>
<div class="w-full max-w-4xl mx-auto">
<!-- Pending attachment previews -->
<div
@@ -515,6 +520,7 @@ const {
streaming,
currentBotId,
sessionId,
activeSession,
activeChatReadOnly,
loadingOlder,
loadingChats,
@@ -0,0 +1,79 @@
<template>
<div
class="w-full rounded-lg border border-rose-200 dark:border-rose-400/20 bg-rose-50/50 dark:bg-rose-950/20 px-4 py-3 cursor-pointer transition-colors hover:bg-rose-50 dark:hover:bg-rose-950/30"
@click="navigateToLogs"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 text-xs font-medium text-rose-600 dark:text-rose-400">
<HeartPulse class="size-3.5" />
{{ t('chat.heartbeatTrigger') }}
</div>
<div class="flex items-center gap-1 text-[11px] text-rose-500/70 dark:text-rose-400/60">
{{ t('chat.viewHeartbeatLogs') }}
<ExternalLink class="size-3" />
</div>
</div>
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<span
v-if="parsed.time"
class="text-muted-foreground"
>{{ t('chat.heartbeatTime') }}</span>
<span v-if="parsed.time">{{ parsed.time }}</span>
<span
v-if="parsed.interval"
class="text-muted-foreground"
>{{ t('chat.heartbeatInterval') }}</span>
<span v-if="parsed.interval">{{ parsed.interval }}</span>
<span
v-if="parsed.lastHeartbeat"
class="text-muted-foreground"
>{{ t('chat.heartbeatLastAt') }}</span>
<span v-if="parsed.lastHeartbeat">{{ parsed.lastHeartbeat }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { HeartPulse, ExternalLink } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const props = defineProps<{
content: string
botId?: string
}>()
const { t } = useI18n()
const router = useRouter()
interface HeartbeatInfo {
interval?: string
time?: string
lastHeartbeat?: string
}
const parsed = computed<HeartbeatInfo>(() => {
const text = props.content ?? ''
const frontmatterMatch = text.match(/---\n([\s\S]*?)\n---/)
if (!frontmatterMatch) return {}
const lines = frontmatterMatch[1].split('\n')
const info: HeartbeatInfo = {}
for (const line of lines) {
const idx = line.indexOf(':')
if (idx < 0) continue
const key = line.slice(0, idx).trim()
const val = line.slice(idx + 1).trim()
if (key === 'interval') info.interval = val
else if (key === 'time') info.time = val
else if (key === 'last_heartbeat') info.lastHeartbeat = val
}
return info
})
function navigateToLogs() {
if (!props.botId) return
router.push({ name: 'bot-detail', params: { botId: props.botId }, query: { tab: 'heartbeat' } })
}
</script>
@@ -1,7 +1,7 @@
<template>
<div
class="flex gap-3 items-start"
:class="message.role === 'user' && isSelf ? 'justify-end' : ''"
:class="message.role === 'user' && isSelf && !isSpecialUserMessage ? 'justify-end' : ''"
>
<!-- Assistant avatar
<div
@@ -27,9 +27,9 @@
/>
</div> -->
<!-- User avatar (other sender, left-aligned) -->
<!-- User avatar (other sender, left-aligned; hidden for special session types) -->
<div
v-if="message.role === 'user' && !isSelf"
v-if="message.role === 'user' && !isSelf && !isSpecialUserMessage"
class="relative shrink-0"
>
<Avatar class="size-8">
@@ -62,9 +62,82 @@
{{ message.senderDisplayName || senderFallbackName }}
</p> -->
<!-- User message -->
<!-- Heartbeat trigger (replaces user message) -->
<div
v-if="message.role === 'user'"
v-if="message.role === 'user' && sessionType === 'heartbeat'"
class="space-y-2"
>
<HeartbeatTriggerBlock
v-for="(block, i) in message.blocks.filter(b => b.type === 'text')"
:key="i"
:content="block.content ?? ''"
:bot-id="botId"
/>
<p
class="text-xs text-muted-foreground/80 mt-1"
:title="fullTimestamp"
>
{{ relativeTimestamp }}
</p>
</div>
<!-- Schedule trigger (replaces user message) -->
<div
v-else-if="message.role === 'user' && sessionType === 'schedule'"
class="space-y-2"
>
<ScheduleTriggerBlock
v-for="(block, i) in message.blocks.filter(b => b.type === 'text')"
:key="i"
:content="block.content ?? ''"
:bot-id="botId"
/>
<p
class="text-xs text-muted-foreground/80 mt-1"
:title="fullTimestamp"
>
{{ relativeTimestamp }}
</p>
</div>
<!-- Subagent user message (full-width markdown box) -->
<div
v-else-if="message.role === 'user' && sessionType === 'subagent'"
class="space-y-2"
>
<div
v-for="(block, i) in message.blocks"
:key="i"
>
<div
v-if="block.type === 'text' && block.content"
class="w-full rounded-lg border border-violet-200 dark:border-violet-400/20 bg-violet-50/50 dark:bg-violet-950/20 px-4 py-3"
>
<div class="prose prose-sm dark:prose-invert max-w-none *:first:mt-0">
<MarkdownRender
:content="block.content"
:is-dark="isDark"
custom-id="chat-msg"
/>
</div>
</div>
<AttachmentBlock
v-else-if="block.type === 'attachment'"
:block="(block as AttachmentBlockType)"
:on-open-media="onOpenMedia"
/>
</div>
<p
class="text-xs text-muted-foreground/80 mt-1"
:title="fullTimestamp"
>
{{ relativeTimestamp }}
</p>
</div>
<!-- Default user message (chat bubble) -->
<div
v-else-if="message.role === 'user'"
class="space-y-2"
>
<div
@@ -175,6 +248,8 @@ import { useSettingsStore } from '@/store/settings'
import ThinkingBlock from './thinking-block.vue'
import ToolCallBlock from './tool-call-block.vue'
import AttachmentBlock from './attachment-block.vue'
import HeartbeatTriggerBlock from './heartbeat-trigger-block.vue'
import ScheduleTriggerBlock from './schedule-trigger-block.vue'
import ChannelBadge from '@/components/chat-list/channel-badge/index.vue'
// import { useUserStore } from '@/store/user'
// import { useChatStore } from '@/store/chat-list'
@@ -195,6 +270,8 @@ const isDark = computed(() => settingsStore.theme === 'dark')
const props = defineProps<{
message: ChatMessage
sessionType?: string
botId?: string
onOpenMedia?: (src: string) => void
}>()
@@ -234,7 +311,13 @@ function cleanUserText(content?: string): string {
.trim()
}
const isSpecialUserMessage = computed(() =>
props.message.role === 'user'
&& (props.sessionType === 'heartbeat' || props.sessionType === 'schedule' || props.sessionType === 'subagent'),
)
const contentClass = computed(() => {
if (isSpecialUserMessage.value) return 'flex-1 max-w-full'
if (props.message.role === 'user') return 'max-w-[80%]'
return 'flex-1 max-w-full'
})
@@ -0,0 +1,101 @@
<template>
<div
class="w-full rounded-lg border border-amber-200 dark:border-amber-400/20 bg-amber-50/50 dark:bg-amber-950/20 px-4 py-3 cursor-pointer transition-colors hover:bg-amber-50 dark:hover:bg-amber-950/30"
@click="navigateToSchedule"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
<Clock class="size-3.5" />
{{ t('chat.scheduleTrigger') }}
</div>
<div class="flex items-center gap-1 text-[11px] text-amber-500/70 dark:text-amber-400/60">
{{ t('chat.viewSchedule') }}
<ExternalLink class="size-3" />
</div>
</div>
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<span
v-if="parsed.name"
class="text-muted-foreground"
>{{ t('chat.scheduleName') }}</span>
<span v-if="parsed.name">{{ parsed.name }}</span>
<span
v-if="parsed.pattern"
class="text-muted-foreground"
>{{ t('chat.schedulePattern') }}</span>
<span v-if="parsed.pattern">
<code class="text-[11px] px-1 py-0.5 rounded bg-amber-100/50 dark:bg-amber-900/30">{{ parsed.pattern }}</code>
</span>
<span
v-if="parsed.description"
class="text-muted-foreground"
>{{ t('chat.scheduleDescription') }}</span>
<span v-if="parsed.description">{{ parsed.description }}</span>
<span
v-if="parsed.maxCalls"
class="text-muted-foreground"
>{{ t('chat.scheduleMaxCalls') }}</span>
<span v-if="parsed.maxCalls">{{ parsed.maxCalls }}</span>
</div>
<div
v-if="parsed.command"
class="mt-2 text-xs text-muted-foreground border-t border-amber-200/50 dark:border-amber-400/10 pt-2"
>
<pre class="whitespace-pre-wrap break-all font-mono text-[11px]">{{ parsed.command }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Clock, ExternalLink } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const props = defineProps<{
content: string
botId?: string
}>()
const { t } = useI18n()
const router = useRouter()
interface ScheduleInfo {
name?: string
description?: string
pattern?: string
maxCalls?: string
command?: string
}
const parsed = computed<ScheduleInfo>(() => {
const text = props.content ?? ''
const frontmatterMatch = text.match(/---\n([\s\S]*?)\n---/)
if (!frontmatterMatch) return {}
const lines = frontmatterMatch[1].split('\n')
const info: ScheduleInfo = {}
for (const line of lines) {
const idx = line.indexOf(':')
if (idx < 0) continue
const key = line.slice(0, idx).trim()
const val = line.slice(idx + 1).trim()
if (key === 'schedule-name') info.name = val
else if (key === 'schedule-description') info.description = val
else if (key === 'cron-pattern') info.pattern = val
else if (key === 'max-calls') info.maxCalls = val
}
const afterFrontmatter = text.slice(text.indexOf('---', text.indexOf('---') + 3) + 3).trim()
if (afterFrontmatter) {
info.command = afterFrontmatter
}
return info
})
function navigateToSchedule() {
if (!props.botId) return
router.push({ name: 'bot-detail', params: { botId: props.botId }, query: { tab: 'schedule' } })
}
</script>
@@ -36,9 +36,9 @@
</Badge>
</div>
<!-- Task list -->
<!-- Task list (only shown while running, before results are available) -->
<div
v-if="tasks.length"
v-if="tasks.length && !results.length"
class="px-3 py-2 space-y-1"
>
<div
@@ -52,9 +52,47 @@
</div>
</div>
<!-- Results -->
<Collapsible
<!-- Results (clickable to navigate to subagent session) -->
<div
v-if="block.done && results.length"
class="px-3 py-2 space-y-1"
>
<component
:is="result.session_id ? 'button' : 'div'"
v-for="(result, idx) in results"
:key="idx"
class="flex items-center gap-1.5 text-xs w-full text-left rounded-md px-1.5 py-1 -mx-1.5 transition-colors"
:class="result.session_id
? 'cursor-pointer hover:bg-accent'
: ''"
@click="result.session_id ? navigateToSession(result.session_id) : undefined"
>
<CircleCheck
v-if="result.success"
class="size-2.5 text-green-500 shrink-0"
/>
<CircleX
v-else
class="size-2.5 text-red-500 shrink-0"
/>
<span class="font-mono text-foreground shrink-0">#{{ idx + 1 }}</span>
<span
v-if="result.task"
class="truncate text-muted-foreground"
:title="result.task"
>
{{ result.task }}
</span>
<ExternalLink
v-if="result.session_id"
class="size-2.5 text-muted-foreground/50 shrink-0 ml-auto"
/>
</component>
</div>
<!-- Detailed results (collapsible) -->
<Collapsible
v-if="block.done && hasDetailedResults"
v-model:open="resultOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
@@ -71,24 +109,6 @@
:key="idx"
class="text-xs"
>
<div class="flex items-center gap-1.5 mb-0.5">
<CircleCheck
v-if="result.success"
class="size-2.5 text-green-500"
/>
<CircleX
v-else
class="size-2.5 text-red-500"
/>
<span class="font-mono text-foreground">#{{ idx + 1 }}</span>
<span
v-if="result.task"
class="truncate text-muted-foreground"
:title="result.task"
>
{{ result.task }}
</span>
</div>
<pre
v-if="result.text"
class="text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto pl-4"
@@ -108,8 +128,11 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, GitBranch, ChevronRight, CircleCheck, CircleX } from 'lucide-vue-next'
import { Check, LoaderCircle, GitBranch, ChevronRight, CircleCheck, CircleX, ExternalLink } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import { useRouter } from 'vue-router'
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
import type { ToolCallBlock } from '@/store/chat-list'
interface SpawnTaskResult {
@@ -122,6 +145,10 @@ interface SpawnTaskResult {
const props = defineProps<{ block: ToolCallBlock }>()
const router = useRouter()
const chatStore = useChatStore()
const { currentBotId } = storeToRefs(chatStore)
const resultOpen = ref(false)
const tasks = computed(() => {
@@ -146,4 +173,18 @@ const results = computed<SpawnTaskResult[]>(() => {
const items = r.results
return Array.isArray(items) ? (items as SpawnTaskResult[]) : []
})
const hasDetailedResults = computed(() =>
results.value.some(r => r.text || r.error),
)
function navigateToSession(sessionId: string) {
const botId = currentBotId.value
if (!botId || !sessionId) return
chatStore.selectSession(sessionId)
router.push({
name: 'chat',
params: { botId, sessionId },
})
}
</script>
+9 -1
View File
@@ -121,7 +121,15 @@ export const useChatStore = defineStore('chat', () => {
sessions.value.find((s) => s.id === sessionId.value) ?? null,
)
const activeChatReadOnly = computed(() => false)
const activeChatReadOnly = computed(() => {
const session = activeSession.value
if (!session) return false
const type = session.type ?? 'chat'
if (type === 'heartbeat' || type === 'schedule' || type === 'subagent') return true
const ct = (session.channel_type ?? '').trim().toLowerCase()
if (ct && ct !== 'web') return true
return false
})
watch(currentBotId, (newId) => {
if (newId) {