mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user