add hermes kanban board (#534)

This commit is contained in:
ekko
2026-05-08 11:32:47 +08:00
committed by GitHub
parent 9fbff08098
commit b0e03ae838
26 changed files with 3467 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
import { request } from '../client'
// ─── Types ──────────────────────────────────────────────────────
export type KanbanTaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
export interface KanbanTask {
id: string
title: string
body: string | null
assignee: string | null
status: KanbanTaskStatus
priority: number
created_by: string | null
created_at: number
started_at: number | null
completed_at: number | null
workspace_kind: string
workspace_path: string | null
tenant: string | null
result: string | null
skills: string[] | null
}
export interface KanbanRun {
id: number
task_id: string
profile: string | null
status: string
outcome: string | null
summary: string | null
error: string | null
metadata: Record<string, unknown> | null
worker_pid: number | null
started_at: number
ended_at: number | null
}
export interface KanbanComment {
id: number
task_id: string
author: string
body: string
created_at: number
}
export interface KanbanEvent {
id: number
task_id: string
kind: string
payload: Record<string, unknown> | null
created_at: number
run_id: number | null
}
export interface KanbanTaskMessage {
id: number | string
session_id: string
role: string
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
}
export interface KanbanTaskSession {
id: string
title: string | null
source: string
model: string
started_at: number
ended_at: number | null
messages: KanbanTaskMessage[]
}
export interface KanbanTaskDetail {
task: KanbanTask
latest_summary: string | null
session?: KanbanTaskSession
comments: KanbanComment[]
events: KanbanEvent[]
runs: KanbanRun[]
}
export interface KanbanStats {
by_status: Record<string, number>
by_assignee: Record<string, number>
total: number
}
export interface KanbanAssignee {
name: string
on_disk: boolean
counts: Record<string, number> | null
}
export interface KanbanCreateRequest {
title: string
body?: string
assignee?: string
priority?: number
tenant?: string
}
// ─── API functions ───────────────────────────────────────────────
export async function listTasks(opts?: {
status?: string
assignee?: string
tenant?: string
}): Promise<KanbanTask[]> {
const params = new URLSearchParams()
if (opts?.status) params.set('status', opts.status)
if (opts?.assignee) params.set('assignee', opts.assignee)
if (opts?.tenant) params.set('tenant', opts.tenant)
const qs = params.toString()
const res = await request<{ tasks: KanbanTask[] }>(`/api/hermes/kanban${qs ? `?${qs}` : ''}`)
return res.tasks
}
export async function getTask(id: string): Promise<KanbanTaskDetail> {
return request<KanbanTaskDetail>(`/api/hermes/kanban/${id}`)
}
export async function createTask(data: KanbanCreateRequest): Promise<KanbanTask> {
const res = await request<{ task: KanbanTask }>('/api/hermes/kanban', {
method: 'POST',
body: JSON.stringify(data),
})
return res.task
}
export async function completeTasks(taskIds: string[], summary?: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/hermes/kanban/complete', {
method: 'POST',
body: JSON.stringify({ task_ids: taskIds, summary }),
})
}
export async function blockTask(taskId: string, reason: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/hermes/kanban/${taskId}/block`, {
method: 'POST',
body: JSON.stringify({ reason }),
})
}
export async function unblockTasks(taskIds: string[]): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/hermes/kanban/unblock', {
method: 'POST',
body: JSON.stringify({ task_ids: taskIds }),
})
}
export async function assignTask(taskId: string, profile: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/hermes/kanban/${taskId}/assign`, {
method: 'POST',
body: JSON.stringify({ profile }),
})
}
export async function getStats(): Promise<KanbanStats> {
const res = await request<{ stats: KanbanStats }>('/api/hermes/kanban/stats')
return res.stats
}
export async function getAssignees(): Promise<KanbanAssignee[]> {
const res = await request<{ assignees: KanbanAssignee[] }>('/api/hermes/kanban/assignees')
return res.assignees
}
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NCollapse, NCollapseItem } from 'naive-ui'
import KanbanTaskCard from './KanbanTaskCard.vue'
import type { KanbanTask, KanbanTaskStatus } from '@/api/hermes/kanban'
const props = defineProps<{
status: KanbanTaskStatus
tasks: KanbanTask[]
}>()
const emit = defineEmits<{
taskClick: [taskId: string]
}>()
const { t } = useI18n()
const statusLabel = computed(() => t(`kanban.columns.${props.status}`, props.status))
const statusCount = computed(() => props.tasks.length)
const statusIcon = computed(() => {
switch (props.status) {
case 'todo': return '○'
case 'ready': return '◎'
case 'running': return '●'
case 'blocked': return '⊘'
case 'done': return '✓'
default: return '○'
}
})
const headerTitle = computed(() => `${statusIcon.value} ${statusLabel.value} (${statusCount.value})`)
</script>
<template>
<div class="kanban-column">
<NCollapse :default-expanded-names="[status]" display-directive="show">
<NCollapseItem :title="headerTitle" :name="status">
<KanbanTaskCard
v-for="task in tasks"
:key="task.id"
:task="task"
@click="emit('taskClick', task.id)"
/>
<div v-if="tasks.length === 0" class="column-empty">
{{ t('kanban.noTasks') }}
</div>
</NCollapseItem>
</NCollapse>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.kanban-column {
flex: 1 1 calc(20% - 12px);
min-width: 200px;
background-color: rgba(var(--accent-primary-rgb), 0.02);
border-radius: $radius-md;
border: 1px solid $border-light;
:deep(.n-collapse) {
--n-title-font-size: 13px;
--n-title-font-weight: 600;
}
:deep(.n-collapse-item__header-main) {
color: $text-primary;
}
:deep(.n-collapse-item__content-wrapper) {
padding: 0 10px 10px;
}
:deep(.n-collapse-item) {
display: flex;
flex-direction: column;
}
}
.column-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 60px;
font-size: 12px;
color: $text-muted;
}
</style>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NSelect, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useKanbanStore } from '@/stores/hermes/kanban'
const emit = defineEmits<{
close: []
created: []
}>()
const { t } = useI18n()
const message = useMessage()
const kanbanStore = useKanbanStore()
const title = ref('')
const body = ref('')
const assignee = ref<string | null>(null)
const priority = ref<number | null>(null)
const saving = ref(false)
const priorityOptions = computed(() => [
{ label: t('kanban.card.priority.low'), value: 1 },
{ label: t('kanban.card.priority.medium'), value: 2 },
{ label: t('kanban.card.priority.high'), value: 3 },
])
const assigneeOptions = computed(() => {
return kanbanStore.assignees.map(a => {
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
return { label: `${a.name} (${total})`, value: a.name }
})
})
async function handleSubmit() {
if (!title.value.trim()) {
message.warning(t('kanban.form.titleRequired'))
return
}
saving.value = true
try {
await kanbanStore.createTask({
title: title.value.trim(),
body: body.value.trim() || undefined,
assignee: assignee.value || undefined,
priority: priority.value ?? undefined,
})
message.success(t('kanban.message.taskCreated'))
emit('created')
emit('close')
} catch (err: any) {
message.error(err.message)
} finally {
saving.value = false
}
}
</script>
<template>
<NModal :show="true" preset="dialog" :title="t('kanban.createTask')" style="width: 480px;" @close="emit('close')">
<NForm label-placement="top">
<NFormItem :label="t('kanban.form.title')">
<NInput v-model:value="title" :placeholder="t('kanban.form.titlePlaceholder')" />
</NFormItem>
<NFormItem :label="t('kanban.form.body')">
<NInput v-model:value="body" type="textarea" :rows="3" :placeholder="t('kanban.form.bodyPlaceholder')" />
</NFormItem>
<NFormItem :label="t('kanban.form.assignee')">
<NSelect v-model:value="assignee" :options="assigneeOptions" :placeholder="t('kanban.form.selectAssignee')" clearable />
</NFormItem>
<NFormItem :label="t('kanban.form.priority')">
<NSelect v-model:value="priority" :options="priorityOptions" :placeholder="t('kanban.form.selectPriority')" clearable />
</NFormItem>
</NForm>
<template #action>
<NButton @click="emit('close')">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="saving" @click="handleSubmit">{{ t('common.create') }}</NButton>
</template>
</NModal>
</template>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NTooltip } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { KanbanTask } from '@/api/hermes/kanban'
const props = defineProps<{
task: KanbanTask
}>()
const emit = defineEmits<{
click: [taskId: string]
}>()
const { t } = useI18n()
const timeAgo = computed(() => {
const diff = Date.now() / 1000 - props.task.created_at
if (diff < 60) return t('kanban.card.timeAgo.justNow')
if (diff < 3600) return t('kanban.card.timeAgo.minutes', { count: Math.floor(diff / 60) })
if (diff < 86400) return t('kanban.card.timeAgo.hours', { count: Math.floor(diff / 3600) })
return t('kanban.card.timeAgo.days', { count: Math.floor(diff / 86400) })
})
const priorityLabel = computed(() => {
if (props.task.priority >= 3) return 'high'
if (props.task.priority === 2) return 'medium'
return 'low'
})
const priorityText = computed(() => {
return t(`kanban.card.priority.${priorityLabel.value}`)
})
</script>
<template>
<div class="kanban-task-card" @click="emit('click', task.id)">
<div class="card-title">{{ task.title }}</div>
<div class="card-meta">
<NTooltip v-if="task.assignee" trigger="hover">
<template #trigger>
<span class="meta-tag assignee-tag">{{ task.assignee }}</span>
</template>
{{ t('kanban.card.assigneeTooltip') }}
</NTooltip>
<span v-if="task.priority >= 2" class="meta-tag priority-tag" :class="priorityLabel">{{ priorityText }}</span>
<span class="meta-time">{{ timeAgo }}</span>
</div>
<div v-if="task.body" class="card-body-preview">{{ task.body.slice(0, 80) }}{{ task.body.length > 80 ? '...' : '' }}</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.kanban-task-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 12px;
cursor: pointer;
transition: border-color $transition-fast, box-shadow $transition-fast;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
}
.card-title {
font-size: 13px;
font-weight: 600;
color: $text-primary;
line-height: 1.4;
word-break: break-word;
}
.card-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.meta-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
}
.assignee-tag {
background: rgba(var(--accent-primary-rgb), 0.1);
color: $accent-primary;
}
.priority-tag {
&.high {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
&.medium {
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.low {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
}
.meta-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.card-body-preview {
font-size: 12px;
color: $text-muted;
margin-top: 6px;
line-height: 1.4;
}
</style>
@@ -0,0 +1,683 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { NDrawer, NDrawerContent, NButton, NSelect, NInput, NSpin, NModal, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { request } from '@/api/client'
import { getTask } from '@/api/hermes/kanban'
import { useKanbanStore } from '@/stores/hermes/kanban'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import type { Session, Message } from '@/stores/hermes/chat'
import type { KanbanTaskDetail } from '@/api/hermes/kanban'
const props = defineProps<{
taskId: string | null
}>()
const emit = defineEmits<{
close: []
updated: []
}>()
const { t } = useI18n()
const router = useRouter()
const message = useMessage()
const kanbanStore = useKanbanStore()
const detail = ref<KanbanTaskDetail | null>(null)
const loading = ref(false)
const assignProfile = ref<string | null>(null)
const blockReason = ref('')
const showBlockInput = ref(false)
const completeSummary = ref('')
const showCompleteInput = ref(false)
const showMessagesModal = ref(false)
const completionSummary = computed(() => {
if (!detail.value) return ''
return detail.value.task.result || detail.value.latest_summary || ''
})
const localizedTaskStatus = computed(() => {
if (!detail.value) return ''
return t(`kanban.columns.${detail.value.task.status}`, detail.value.task.status)
})
const sessionResults = ref<any[]>([])
const sessionLoading = ref(false)
const showSessions = ref(false)
const latestRunProfile = computed(() => {
if (!detail.value) return null
return [...detail.value.runs].reverse().find(run => run.profile)?.profile || null
})
async function searchTaskSessions() {
if (!detail.value) return
const profile = latestRunProfile.value
if (!profile) return
showSessions.value = !showSessions.value
if (!showSessions.value) return
sessionLoading.value = true
try {
const res = await request<{ results: any[] }>(
`/api/hermes/kanban/search-sessions?task_id=${encodeURIComponent(detail.value.task.id)}&profile=${encodeURIComponent(profile)}`
)
sessionResults.value = res.results
} catch {
sessionResults.value = []
} finally {
sessionLoading.value = false
}
}
function openResultDetail() {
if (detail.value?.session) {
showMessagesModal.value = true
}
}
const historySession = computed<Session | null>(() => {
const s = detail.value?.session
if (!s) return null
return {
id: s.id,
title: s.title || '',
source: s.source,
messages: s.messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({
id: String(m.id),
role: m.role as Message['role'],
content: m.content,
timestamp: m.timestamp,
})),
createdAt: s.started_at,
updatedAt: s.ended_at || s.started_at,
model: s.model,
messageCount: s.messages.length,
endedAt: s.ended_at,
}
})
const assigneeOptions = computed(() => {
return kanbanStore.assignees.map(a => {
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
return { label: `${a.name} (${total})`, value: a.name }
})
})
watch(() => props.taskId, async (id) => {
if (!id) {
detail.value = null
return
}
loading.value = true
try {
detail.value = await getTask(id)
} catch (err: any) {
message.error(t('kanban.message.loadFailed'))
} finally {
loading.value = false
}
}, { immediate: true })
function formatTime(ts: number | null) {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString()
}
async function handleComplete() {
if (!props.taskId) return
if (!showCompleteInput.value) {
showCompleteInput.value = true
return
}
try {
await kanbanStore.completeTasks([props.taskId], completeSummary.value.trim() || undefined)
message.success(t('kanban.message.taskCompleted'))
showCompleteInput.value = false
completeSummary.value = ''
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleBlock() {
if (!props.taskId || !blockReason.value.trim()) return
try {
await kanbanStore.blockTask(props.taskId, blockReason.value.trim())
message.success(t('kanban.message.taskBlocked'))
showBlockInput.value = false
blockReason.value = ''
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleUnblock() {
if (!props.taskId) return
try {
await kanbanStore.unblockTasks([props.taskId])
message.success(t('kanban.message.taskUnblocked'))
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleAssign() {
if (!props.taskId || !assignProfile.value) return
try {
await kanbanStore.assignTask(props.taskId, assignProfile.value)
message.success(t('kanban.message.taskAssigned'))
assignProfile.value = null
if (detail.value) {
detail.value = await getTask(props.taskId)
}
emit('updated')
} catch (err: any) {
message.error(err.message)
}
}
</script>
<template>
<NDrawer :show="!!taskId" :width="420" placement="right" @update:show="(v: boolean) => { if (!v) emit('close') }">
<NDrawerContent :title="detail?.task.title || ''" closable>
<NSpin :show="loading">
<template v-if="detail">
<!-- Metadata -->
<div class="detail-section">
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.status') }}</span>
<span class="detail-value status-badge" :class="detail.task.status">{{ localizedTaskStatus }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.assignee') }}</span>
<span class="detail-value">{{ detail.task.assignee || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.priority') }}</span>
<span class="detail-value">{{ detail.task.priority }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.tenant') }}</span>
<span class="detail-value">{{ detail.task.tenant || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.createdAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.created_at) }}</span>
</div>
<div v-if="detail.task.started_at" class="detail-row">
<span class="detail-label">{{ t('kanban.detail.startedAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.started_at) }}</span>
</div>
<div v-if="detail.task.completed_at" class="detail-row">
<span class="detail-label">{{ t('kanban.detail.completedAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.completed_at) }}</span>
</div>
</div>
<!-- Body -->
<div v-if="detail.task.body" class="detail-section">
<div class="section-title">{{ t('kanban.form.body') }}</div>
<div class="detail-body">{{ detail.task.body }}</div>
</div>
<!-- Result / Summary -->
<div v-if="completionSummary" class="detail-section">
<div class="section-title">{{ t('kanban.detail.result') }}</div>
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
</div>
<!-- Actions (only for non-completed tasks) -->
<div v-if="detail.task.status !== 'done'" class="detail-section">
<div class="section-title">{{ t('kanban.action.title') }}</div>
<div class="action-group">
<template v-if="!showCompleteInput">
<NButton size="small" @click="showCompleteInput = true">
{{ t('kanban.action.complete') }}
</NButton>
</template>
<div v-else class="complete-input">
<NInput v-model:value="completeSummary" size="small" :placeholder="t('kanban.action.completeSummary')" />
<NButton size="small" type="primary" @click="handleComplete">{{ t('common.ok') }}</NButton>
<NButton size="small" @click="showCompleteInput = false; completeSummary = ''">{{ t('common.cancel') }}</NButton>
</div>
<template v-if="detail.task.status === 'blocked'">
<NButton size="small" @click="handleUnblock">{{ t('kanban.action.unblock') }}</NButton>
</template>
<template v-else>
<NButton v-if="!showBlockInput" size="small" @click="showBlockInput = true">{{ t('kanban.action.block') }}</NButton>
<div v-else class="block-input">
<NInput v-model:value="blockReason" size="small" :placeholder="t('kanban.action.blockReason')" />
<NButton size="small" type="primary" @click="handleBlock">{{ t('common.ok') }}</NButton>
</div>
</template>
</div>
<div v-if="detail.task.status !== 'running'" class="assign-group">
<NSelect v-model:value="assignProfile" :options="assigneeOptions" size="small" :placeholder="t('kanban.action.assignTo')" style="flex: 1;" />
<NButton size="small" :disabled="!assignProfile" @click="handleAssign">{{ t('kanban.action.assign') }}</NButton>
</div>
</div>
<!-- Related Sessions -->
<div v-if="detail.runs.length > 0" class="detail-section">
<div class="section-title" style="cursor: pointer;" @click="searchTaskSessions">
{{ t('kanban.detail.sessions') }}
<NSpin v-if="sessionLoading" :size="12" style="margin-left: 6px;" />
</div>
<div v-if="showSessions && sessionResults.length > 0" class="session-list">
<div v-for="session in sessionResults" :key="session.id" class="session-item" @click="router.push({ name: 'hermes.chat', query: { session: session.id } })">
<div class="session-title">{{ session.title || session.id }}</div>
<div class="session-meta">
<span>{{ session.source }}</span>
<span>{{ session.model }}</span>
<span>{{ formatTime(session.started_at) }}</span>
</div>
</div>
</div>
<div v-if="showSessions && !sessionLoading && sessionResults.length === 0" class="column-empty">{{ t('kanban.detail.noSessions') }}</div>
</div>
<!-- Runs -->
<div v-if="detail.runs.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.runs') }}</div>
<div v-for="run in detail.runs" :key="run.id" class="run-item">
<div class="run-header">
<span class="run-status" :class="run.status">{{ run.status }}</span>
<span class="run-profile">{{ run.profile || '—' }}</span>
<span class="run-time">{{ formatTime(run.started_at) }}</span>
</div>
<div v-if="run.summary" class="run-summary">{{ run.summary }}</div>
<div v-if="run.error" class="run-error">{{ run.error }}</div>
</div>
</div>
<!-- Comments -->
<div v-if="detail.comments.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.comments') }}</div>
<div v-for="comment in detail.comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-time">{{ formatTime(comment.created_at) }}</span>
</div>
<div class="comment-body">{{ comment.body }}</div>
</div>
</div>
<!-- Events -->
<div v-if="detail.events.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.events') }}</div>
<div v-for="event in detail.events.slice(-10)" :key="event.id" class="event-item">
<span class="event-kind">{{ event.kind }}</span>
<span class="event-time">{{ formatTime(event.created_at) }}</span>
</div>
</div>
</template>
</NSpin>
</NDrawerContent>
</NDrawer>
<!-- Session messages modal (click result summary) -->
<NModal v-if="historySession" :show="showMessagesModal" preset="card" :title="detail?.task.title || ''" :style="{ width: '900px', maxWidth: 'calc(100vw - 48px)' }" @close="showMessagesModal = false">
<div class="messages-modal-body">
<HistoryMessageList :session="historySession" />
</div>
</NModal>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.detail-section {
margin-bottom: 20px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.result-summary {
cursor: pointer;
border-radius: $radius-sm;
padding: 8px 10px;
background: rgba(var(--accent-primary-rgb), 0.04);
border: 1px solid $border-light;
font-size: 13px;
color: $text-secondary;
line-height: 1.5;
transition: border-color $transition-fast;
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
}
.result-detail {
margin-top: 10px;
padding: 10px;
background: rgba(var(--accent-primary-rgb), 0.02);
border: 1px solid $border-light;
border-radius: $radius-sm;
}
.meta-label {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.3px;
margin: 8px 0 4px;
&:first-child { margin-top: 0; }
}
.meta-list {
list-style: none;
padding: 0;
margin: 0;
li {
font-size: 12px;
color: $text-secondary;
padding: 2px 0;
code {
font-family: $font-code;
font-size: 11px;
background: rgba(var(--accent-primary-rgb), 0.06);
padding: 1px 4px;
border-radius: 3px;
word-break: break-all;
}
}
}
.meta-kv {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-kv-row {
display: flex;
gap: 8px;
font-size: 12px;
}
.meta-kv-key {
color: $text-muted;
font-family: $font-code;
font-size: 11px;
min-width: 100px;
flex-shrink: 0;
}
.meta-kv-val {
color: $text-secondary;
}
.artifact-link {
cursor: pointer;
transition: color $transition-fast;
&:hover { color: $accent-primary; }
}
.artifact-modal-body,
.messages-modal-body {
max-height: 65vh;
overflow: hidden;
padding: 4px 0;
:deep(.message-list) {
max-height: 65vh;
background: transparent;
padding: 0;
}
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid $border-light;
}
.detail-label {
font-size: 12px;
color: $text-muted;
}
.detail-value {
font-size: 12px;
color: $text-primary;
}
.status-badge {
padding: 1px 8px;
border-radius: 4px;
font-weight: 500;
&.running {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.done {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.blocked {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
&.ready {
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.triage, &.archived {
background: rgba(128, 128, 128, 0.12);
color: $text-muted;
}
}
.detail-body {
font-size: 13px;
color: $text-secondary;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.action-group {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.block-input,
.complete-input {
display: flex;
gap: 6px;
flex: 1;
}
.assign-group {
display: flex;
gap: 8px;
}
.run-item,
.comment-item {
padding: 8px 0;
border-bottom: 1px solid $border-light;
&:last-child {
border-bottom: none;
}
}
.run-header,
.comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.run-status {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
&.running {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.done, &.completed {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.crashed, &.failed {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
}
.run-profile,
.comment-author {
font-size: 12px;
font-weight: 500;
color: $text-primary;
}
.run-time,
.comment-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.run-summary,
.run-error,
.comment-body {
font-size: 12px;
color: $text-secondary;
line-height: 1.4;
}
.run-error {
color: $error;
}
.event-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.event-kind {
font-size: 11px;
font-family: $font-code;
color: $accent-primary;
}
.event-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.session-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.session-item {
padding: 8px 10px;
border-radius: $radius-sm;
border: 1px solid $border-light;
cursor: pointer;
transition: border-color $transition-fast;
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
}
.session-title {
font-size: 13px;
font-weight: 500;
color: $text-primary;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
display: flex;
gap: 8px;
font-size: 11px;
color: $text-muted;
}
.session-messages {
display: flex;
flex-direction: column;
gap: 10px;
}
.session-msg {
padding: 10px 12px;
border-radius: $radius-sm;
border: 1px solid $border-light;
&.user {
background: rgba(var(--accent-primary-rgb), 0.04);
}
&.assistant {
background: transparent;
}
}
.session-msg-role {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
margin-bottom: 6px;
}
.session-msg-content {
font-size: 13px;
color: $text-secondary;
line-height: 1.5;
:deep(p) {
margin: 0 0 8px;
&:last-child { margin-bottom: 0; }
}
}
</style>
@@ -135,6 +135,14 @@ function openChangelog() {
</svg>
<span>{{ t("sidebar.jobs") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.kanban' }" @click="handleNav('hermes.kanban')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="5" height="18" rx="1" />
<rect x="10" y="3" width="5" height="12" rx="1" />
<rect x="17" y="3" width="5" height="18" rx="1" />
</svg>
<span>{{ t("sidebar.kanban") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.channels' }" @click="handleNav('hermes.channels')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+84
View File
@@ -73,6 +73,7 @@ export default {
apiRelay: 'API Relay',
history: 'History',
jobs: 'Jobs',
kanban: 'Kanban',
models: 'Models',
profiles: 'Profiles',
skills: 'Skills',
@@ -216,6 +217,89 @@ export default {
speechNotSupported: 'Voice playback not supported in this browser',
},
// Kanban
kanban: {
title: 'Kanban Board',
createTask: 'New Task',
noTasks: 'No tasks',
allStatuses: 'All Statuses',
allAssignees: 'All Assignees',
columns: {
triage: 'Triage',
todo: 'To Do',
ready: 'Ready',
running: 'Running',
blocked: 'Blocked',
done: 'Done',
archived: 'Archived',
},
form: {
title: 'Title',
titlePlaceholder: 'Task title',
titleRequired: 'Title is required',
body: 'Description',
bodyPlaceholder: 'Task description (optional)',
assignee: 'Assignee',
selectAssignee: 'Select assignee...',
priority: 'Priority',
selectPriority: 'Select priority...',
},
card: {
assigneeTooltip: 'Assignee',
priority: {
low: 'Low',
medium: 'Medium',
high: 'High',
},
timeAgo: {
justNow: 'just now',
minutes: '{count}m ago',
hours: '{count}h ago',
days: '{count}d ago',
},
},
detail: {
status: 'Status',
assignee: 'Assignee',
priority: 'Priority',
tenant: 'Tenant',
createdAt: 'Created',
startedAt: 'Started',
completedAt: 'Completed',
comments: 'Comments',
events: 'Events',
runs: 'Runs',
result: 'Result',
sessions: 'Related Sessions',
sessionMessages: 'Session Messages',
noSessions: 'No related sessions found.',
artifacts: 'Artifacts',
sources: 'Sources',
highlights: 'Highlights',
},
action: {
title: 'Actions',
complete: 'Complete',
completeSummary: 'Completion summary (optional)',
block: 'Block',
blockReason: 'Reason for blocking',
unblock: 'Unblock',
assign: 'Assign',
assignTo: 'Assign to...',
},
message: {
taskCreated: 'Task created',
taskCompleted: 'Task completed',
taskBlocked: 'Task blocked',
taskUnblocked: 'Task unblocked',
taskAssigned: 'Task assigned',
loadFailed: 'Failed to load task',
},
stats: {
total: 'Total',
},
},
// Jobs
jobs: {
title: 'Scheduled Jobs',
+84
View File
@@ -73,6 +73,7 @@ export default {
apiRelay: '中转站',
history: '历史',
jobs: '任务',
kanban: '看板',
models: '模型',
profiles: '用户',
skills: '技能',
@@ -216,6 +217,89 @@ export default {
speechNotSupported: '此浏览器不支持语音播放',
},
// 看板
kanban: {
title: '看板',
createTask: '新建任务',
noTasks: '暂无任务',
allStatuses: '全部状态',
allAssignees: '全部负责人',
columns: {
triage: '待分拣',
todo: '待办',
ready: '就绪',
running: '进行中',
blocked: '阻塞',
done: '已完成',
archived: '已归档',
},
form: {
title: '标题',
titlePlaceholder: '任务标题',
titleRequired: '标题不能为空',
body: '描述',
bodyPlaceholder: '任务描述(可选)',
assignee: '负责人',
selectAssignee: '选择负责人...',
priority: '优先级',
selectPriority: '选择优先级...',
},
card: {
assigneeTooltip: '负责人',
priority: {
low: '低',
medium: '中',
high: '高',
},
timeAgo: {
justNow: '刚刚',
minutes: '{count}分钟前',
hours: '{count}小时前',
days: '{count}天前',
},
},
detail: {
status: '状态',
assignee: '负责人',
priority: '优先级',
tenant: '租户',
createdAt: '创建时间',
startedAt: '开始时间',
completedAt: '完成时间',
comments: '评论',
events: '事件',
runs: '运行记录',
result: '完成结果',
sessions: '关联会话',
sessionMessages: '会话记录',
noSessions: '未找到关联会话。',
artifacts: '产出文件',
sources: '数据来源',
highlights: '关键信息',
},
action: {
title: '操作',
complete: '完成',
completeSummary: '完成摘要(可选)',
block: '阻塞',
blockReason: '阻塞原因',
unblock: '解除阻塞',
assign: '分配',
assignTo: '分配给...',
},
message: {
taskCreated: '任务已创建',
taskCompleted: '任务已完成',
taskBlocked: '任务已阻塞',
taskUnblocked: '任务已解除阻塞',
taskAssigned: '任务已分配',
loadFailed: '加载任务失败',
},
stats: {
total: '总计',
},
},
// 定时任务
jobs: {
title: '定时任务',
+5
View File
@@ -25,6 +25,11 @@ const router = createRouter({
name: 'hermes.jobs',
component: () => import('@/views/hermes/JobsView.vue'),
},
{
path: '/hermes/kanban',
name: 'hermes.kanban',
component: () => import('@/views/hermes/KanbanView.vue'),
},
{
path: '/hermes/models',
name: 'hermes.models',
+110
View File
@@ -0,0 +1,110 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as kanbanApi from '@/api/hermes/kanban'
import type { KanbanTask, KanbanStats, KanbanAssignee } from '@/api/hermes/kanban'
export const useKanbanStore = defineStore('kanban', () => {
const tasks = ref<KanbanTask[]>([])
const stats = ref<KanbanStats | null>(null)
const assignees = ref<KanbanAssignee[]>([])
const loading = ref(false)
const filterStatus = ref<string | null>(null)
const filterAssignee = ref<string | null>(null)
async function fetchTasks() {
loading.value = true
try {
tasks.value = await kanbanApi.listTasks({
status: filterStatus.value || undefined,
assignee: filterAssignee.value || undefined,
})
} catch (err) {
console.error('Failed to fetch kanban tasks:', err)
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
stats.value = await kanbanApi.getStats()
} catch (err) {
console.error('Failed to fetch kanban stats:', err)
}
}
async function fetchAssignees() {
try {
assignees.value = await kanbanApi.getAssignees()
} catch (err) {
console.error('Failed to fetch kanban assignees:', err)
}
}
async function createTask(data: { title: string; body?: string; assignee?: string; priority?: number; tenant?: string }) {
const task = await kanbanApi.createTask(data)
tasks.value.unshift(task)
await fetchStats()
return task
}
async function completeTasks(taskIds: string[], summary?: string) {
await kanbanApi.completeTasks(taskIds, summary)
for (const id of taskIds) {
const task = tasks.value.find(t => t.id === id)
if (task) task.status = 'done'
}
await fetchStats()
}
async function blockTask(taskId: string, reason: string) {
await kanbanApi.blockTask(taskId, reason)
const task = tasks.value.find(t => t.id === taskId)
if (task) task.status = 'blocked'
await fetchStats()
}
async function unblockTasks(taskIds: string[]) {
await kanbanApi.unblockTasks(taskIds)
for (const id of taskIds) {
const task = tasks.value.find(t => t.id === id)
if (task) task.status = 'ready'
}
await fetchStats()
}
async function assignTask(taskId: string, profile: string) {
await kanbanApi.assignTask(taskId, profile)
const task = tasks.value.find(t => t.id === taskId)
if (task) task.assignee = profile
}
function setFilter(key: 'status' | 'assignee', value: string | null) {
if (key === 'status') filterStatus.value = value
else filterAssignee.value = value
}
async function refreshAll() {
await Promise.all([fetchTasks(), fetchStats(), fetchAssignees()])
}
return {
tasks,
stats,
assignees,
loading,
filterStatus,
filterAssignee,
fetchTasks,
fetchStats,
fetchAssignees,
createTask,
completeTasks,
blockTask,
unblockTasks,
assignTask,
setFilter,
refreshAll,
}
})
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { NButton, NSelect, NSpin, NCollapse, NCollapseItem } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import KanbanTaskCard from '@/components/hermes/kanban/KanbanTaskCard.vue'
import KanbanTaskDrawer from '@/components/hermes/kanban/KanbanTaskDrawer.vue'
import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue'
import { useKanbanStore } from '@/stores/hermes/kanban'
import type { KanbanTaskStatus } from '@/api/hermes/kanban'
const { t } = useI18n()
const kanbanStore = useKanbanStore()
const showCreateForm = ref(false)
const selectedTaskId = ref<string | null>(null)
const refreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
const boardStatuses: KanbanTaskStatus[] = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived']
const tasksByStatus = computed(() => {
const grouped: Record<string, typeof kanbanStore.tasks> = {}
for (const status of boardStatuses) {
grouped[status] = kanbanStore.tasks
.filter(t => t.status === status)
.sort((a, b) => b.created_at - a.created_at)
}
return grouped
})
const statusFilterOptions = computed(() => [
{ label: t('kanban.allStatuses'), value: '' },
...boardStatuses.map(s => ({ label: t(`kanban.columns.${s}`, s), value: s })),
])
const assigneeFilterOptions = computed(() => [
{ label: t('kanban.allAssignees'), value: '' },
...kanbanStore.assignees.map(a => {
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
return { label: `${a.name} (${total})`, value: a.name }
}),
])
const filterStatusValue = computed({
get: () => kanbanStore.filterStatus || '',
set: (v: string) => kanbanStore.setFilter('status', v || null),
})
const filterAssigneeValue = computed({
get: () => kanbanStore.filterAssignee || '',
set: (v: string) => kanbanStore.setFilter('assignee', v || null),
})
onMounted(async () => {
await kanbanStore.refreshAll()
refreshTimer.value = setInterval(() => {
if (document.visibilityState === 'visible') {
void Promise.all([kanbanStore.fetchTasks(), kanbanStore.fetchStats()])
}
}, 15000)
})
onUnmounted(() => {
if (refreshTimer.value) clearInterval(refreshTimer.value)
})
function handleTaskClick(taskId: string) {
selectedTaskId.value = taskId
}
function handleDrawerClose() {
selectedTaskId.value = null
}
async function handleDrawerUpdated() {
await Promise.all([kanbanStore.fetchTasks(), kanbanStore.fetchStats()])
}
async function handleApplyFilter() {
await kanbanStore.fetchTasks()
}
async function handleTaskCreated() {
await Promise.all([kanbanStore.fetchTasks(), kanbanStore.fetchStats()])
}
</script>
<template>
<div class="kanban-view">
<header class="page-header">
<h2 class="header-title">{{ t('kanban.title') }}</h2>
<div class="header-actions">
<NSelect
v-model:value="filterStatusValue"
:options="statusFilterOptions"
size="small"
style="width: 150px;"
@update:value="handleApplyFilter"
/>
<NSelect
v-model:value="filterAssigneeValue"
:options="assigneeFilterOptions"
size="small"
style="width: 170px;"
@update:value="handleApplyFilter"
/>
<NButton type="primary" size="small" @click="showCreateForm = true">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
{{ t('kanban.createTask') }}
</NButton>
</div>
</header>
<!-- Stats bar -->
<div v-if="kanbanStore.stats" class="stats-bar">
<div v-for="status in boardStatuses" :key="status" class="stat-chip" :class="status">
<span class="stat-count">{{ kanbanStore.stats.by_status[status] || 0 }}</span>
<span class="stat-label">{{ t(`kanban.columns.${status}`, status) }}</span>
</div>
<div class="stat-chip total">
<span class="stat-count">{{ kanbanStore.stats.total }}</span>
<span class="stat-label">{{ t('kanban.stats.total') }}</span>
</div>
</div>
<!-- Board -->
<NSpin :show="kanbanStore.loading && kanbanStore.tasks.length === 0">
<div class="kanban-board">
<NCollapse>
<NCollapseItem
v-for="status in boardStatuses"
:key="status"
:title="`${t(`kanban.columns.${status}`, status)} (${tasksByStatus[status].length})`"
:name="status"
>
<div class="task-list">
<KanbanTaskCard
v-for="task in tasksByStatus[status]"
:key="task.id"
:task="task"
@click="handleTaskClick(task.id)"
/>
<div v-if="tasksByStatus[status].length === 0" class="column-empty">
{{ t('kanban.noTasks') }}
</div>
</div>
</NCollapseItem>
</NCollapse>
</div>
</NSpin>
<!-- Task detail drawer -->
<KanbanTaskDrawer
:task-id="selectedTaskId"
@close="handleDrawerClose"
@updated="handleDrawerUpdated"
/>
<!-- Create form -->
<KanbanCreateForm
v-if="showCreateForm"
@close="showCreateForm = false"
@created="handleTaskCreated"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.kanban-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.page-header {
padding: 21px 20px;
border-bottom: 1px solid $border-color;
}
.header-title {
font-size: 16px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.stats-bar {
display: flex;
gap: 8px;
padding: 12px 20px;
flex-shrink: 0;
flex-wrap: wrap;
}
.stat-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
border: 1px solid $border-light;
&.triage, &.todo, &.ready { border-left: 3px solid $text-muted; }
&.running { border-left: 3px solid $accent-primary; }
&.blocked { border-left: 3px solid $error; }
&.done { border-left: 3px solid $success; }
&.archived { border-left: 3px solid $border-color; }
&.total { border-left: 3px solid $text-primary; }
}
.stat-count {
font-weight: 600;
color: $text-primary;
}
.stat-label {
color: $text-muted;
}
.kanban-board {
padding: 14px 20px 20px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.task-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.column-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 40px;
font-size: 12px;
color: $text-muted;
}
@media (max-width: $breakpoint-mobile) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.header-actions {
flex-wrap: wrap;
width: 100%;
}
.kanban-board {
padding: 0 12px 12px;
}
}
</style>