feat: 完善生成记录耗时展示 (#65)
This commit is contained in:
@@ -1,19 +1,70 @@
|
||||
const parseTime = value => {
|
||||
if (!value) return null
|
||||
const timestamp = new Date(value).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : null
|
||||
}
|
||||
|
||||
export function durationText(milliseconds) {
|
||||
if (!Number.isFinite(milliseconds) || milliseconds < 0) return '时间异常'
|
||||
if (milliseconds < 100) return '<0.1 秒'
|
||||
const totalSeconds = milliseconds / 1000
|
||||
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)} 秒`
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = (totalSeconds - minutes * 60).toFixed(1).padStart(4, '0')
|
||||
if (minutes < 60) return `${minutes} 分 ${seconds} 秒`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours} 小时 ${String(minutes % 60).padStart(2, '0')} 分 ${String(seconds).padStart(2, '0')} 秒`
|
||||
}
|
||||
|
||||
const between = (start, end) => {
|
||||
const startTime = parseTime(start)
|
||||
const endTime = parseTime(end)
|
||||
if (startTime === null || endTime === null) return '-'
|
||||
return durationText(endTime - startTime)
|
||||
}
|
||||
|
||||
export function generationTiming(generation, now = Date.now()) {
|
||||
const row = generation || {}
|
||||
const terminal = row.status === 'succeeded' || row.status === 'failed'
|
||||
const end = terminal ? row.completed_at : new Date(now).toISOString()
|
||||
const providerAttempts = Array.isArray(row.attempts)
|
||||
? row.attempts.filter(item => item && item.type === 'provider' && Number.isFinite(Number(item.latency_ms)) && Number(item.latency_ms) >= 0)
|
||||
: []
|
||||
const upstreamMS = providerAttempts.reduce((sum, item) => sum + Number(item.latency_ms), 0)
|
||||
return {
|
||||
total: between(row.created_at, end),
|
||||
queue: row.started_at ? between(row.created_at, row.started_at) : '-',
|
||||
processing: row.started_at ? between(row.started_at, end) : '-',
|
||||
upstream: providerAttempts.length ? durationText(upstreamMS) : '-'
|
||||
}
|
||||
}
|
||||
|
||||
export function kindText(value) {
|
||||
return ({ text: '文本', image: '图片' })[value] || '未知'
|
||||
}
|
||||
|
||||
const eventTitle = attempt => {
|
||||
if (attempt.type === 'lease') return '任务认领'
|
||||
if (attempt.type === 'local_rate_limit') return '本地限流'
|
||||
if (attempt.type === 'provider') return attempt.provider_model_id ? `模型 #${attempt.provider_model_id}` : '模型未记录'
|
||||
if (attempt.type === 'provider') {
|
||||
const provider = attempt.provider_name || 'Provider 未记录'
|
||||
const model = attempt.model_name || (attempt.provider_model_id ? `模型 #${attempt.provider_model_id}` : '模型未记录')
|
||||
return `${provider} · ${model}`
|
||||
}
|
||||
return '系统事件'
|
||||
}
|
||||
|
||||
export function attemptPresentation(value) {
|
||||
const attempt = value && typeof value === 'object' ? value : {}
|
||||
const isProvider = attempt.type === 'provider'
|
||||
const presentation = {
|
||||
label: isProvider ? (attempt.provider_ordinal ? `尝试 ${attempt.provider_ordinal}` : 'Provider 尝试') : '系统事件',
|
||||
title: eventTitle(attempt),
|
||||
status: '进行中',
|
||||
duration: Number.isFinite(Number(attempt.latency_ms)) && Number(attempt.latency_ms) >= 0 ? durationText(Number(attempt.latency_ms)) : between(attempt.started_at, attempt.finished_at),
|
||||
type: 'warning',
|
||||
timestamp: attempt.finished_at || attempt.started_at || attempt.deferred_until || ''
|
||||
}
|
||||
|
||||
if (attempt.error_code) {
|
||||
presentation.status = attempt.error_code
|
||||
presentation.type = 'danger'
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<BasicLayout><template #wrapper><main class="chorus-page">
|
||||
<header class="page-heading"><div><h1>生成记录</h1><p>按任务查看渲染 Prompt、错误摘要和每次上游尝试;敏感响应不会在此展示。</p></div><el-button :loading="loading" @click="load"><i class="ri-refresh-line" /> 刷新</el-button></header>
|
||||
<div class="toolbar"><el-input v-model.trim="keyword" clearable placeholder="任务编号 / 用户编号 / 错误码" style="width: 290px" /><el-select v-model="status" clearable placeholder="全部状态" style="width: 140px"><el-option v-for="item in statuses" :key="item" :label="statusText(item)" :value="item" /></el-select><el-button @click="reset">重置</el-button><span class="spacer" /><span class="muted">最近 {{ filtered.length }} 条</span></div>
|
||||
<div class="toolbar"><el-input v-model.trim="keyword" clearable placeholder="任务编号 / 用户账号 / 错误码" style="width: 290px" /><el-select v-model="status" clearable placeholder="全部状态" style="width: 140px"><el-option v-for="item in statuses" :key="item" :label="statusText(item)" :value="item" /></el-select><el-button @click="reset">重置</el-button><span class="spacer" /><span class="muted">显示 {{ filtered.length }} 条</span></div>
|
||||
<div class="generation-grid">
|
||||
<section class="table-panel"><el-table v-loading="loading" :data="filtered" border highlight-current-row empty-text="暂无生成记录" @current-change="selected=$event">
|
||||
<el-table-column prop="id" label="任务编号" min-width="115"><template #default="{ row }"><span class="mono">#{{ row.id }}</span></template></el-table-column><el-table-column prop="user_id" label="用户" width="90"><template #default="{ row }">#{{ row.user_id }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template></el-table-column><el-table-column prop="kind" label="类型" width="80" /><el-table-column prop="created_at" label="创建时间" width="175"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column>
|
||||
<el-table-column prop="id" label="任务编号" min-width="105"><template #default="{ row }"><span class="mono">#{{ row.id }}</span></template></el-table-column><el-table-column label="用户账号" min-width="135"><template #default="{ row }"><strong>{{ row.username }}</strong><span v-if="row.display_name" class="user-name"> {{ row.display_name }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template></el-table-column><el-table-column label="类型" width="70"><template #default="{ row }">{{ kindText(row.kind) }}</template></el-table-column><el-table-column label="总耗时" width="125"><template #default="{ row }"><span class="numeric">{{ timing(row).total }}</span></template></el-table-column><el-table-column prop="created_at" label="创建时间" width="175"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column>
|
||||
</el-table></section>
|
||||
<section class="generation-detail" aria-label="生成任务详情">
|
||||
<div v-if="!selected" class="empty-state"><i class="ri-file-search-line detail-icon" /><p>选择一条生成记录查看详情</p></div>
|
||||
<template v-else><div class="detail-head"><h2>任务 #{{ selected.id }}</h2><el-tag :type="statusType(selected.status)">{{ statusText(selected.status) }}</el-tag></div>
|
||||
<dl class="detail-list"><dt>用户</dt><dd>#{{ selected.user_id }}</dd><dt>生成类型</dt><dd>{{ selected.kind }}</dd><dt>尝试次数</dt><dd>{{ selected.provider_attempt_count }}</dd><dt>完成时间</dt><dd>{{ formatTime(selected.completed_at) }}</dd><dt>错误码</dt><dd :class="selected.error_code ? 'danger-text mono' : ''">{{ selected.error_code || '-' }}</dd><dt>错误摘要</dt><dd>{{ selected.error_message || '-' }}</dd><dt>渲染 Prompt</dt><dd class="prompt">{{ selected.rendered_prompt || '-' }}</dd></dl>
|
||||
<h3>尝试记录</h3><el-timeline v-if="attempts.length"><el-timeline-item v-for="(attempt, index) in attempts" :key="index" :type="attempt.presentation.type" :timestamp="attempt.presentation.timestamp"><strong>尝试 {{ index + 1 }} · {{ attempt.presentation.title }}</strong><p>{{ attempt.presentation.status }}<span v-if="attempt.error_message"> · {{ attempt.error_message }}</span></p></el-timeline-item></el-timeline><p v-else class="muted">尚无尝试记录。</p>
|
||||
<dl class="detail-list"><dt>用户账号</dt><dd>{{ selected.username }}<span v-if="selected.display_name">({{ selected.display_name }})</span></dd><dt>生成类型</dt><dd>{{ kindText(selected.kind) }}</dd><dt>Provider 尝试</dt><dd>{{ selected.provider_attempt_count }}</dd><dt>总耗时</dt><dd class="numeric">{{ selectedTiming.total }}</dd><dt>排队耗时</dt><dd class="numeric">{{ selectedTiming.queue }}</dd><dt>处理耗时</dt><dd class="numeric">{{ selectedTiming.processing }}</dd><dt>上游耗时</dt><dd class="numeric">{{ selectedTiming.upstream }}</dd><dt>提交时间</dt><dd>{{ formatTime(selected.created_at) }}</dd><dt>开始时间</dt><dd>{{ formatTime(selected.started_at) }}</dd><dt>完成时间</dt><dd>{{ formatTime(selected.completed_at) }}</dd><template v-if="selected.error_code"><dt>错误码</dt><dd class="danger-text mono">{{ selected.error_code }}</dd></template><template v-if="selected.error_message"><dt>错误摘要</dt><dd>{{ selected.error_message }}</dd></template></dl>
|
||||
<el-collapse class="prompt-collapse"><el-collapse-item title="渲染 Prompt"><pre class="prompt">{{ selected.rendered_prompt || '-' }}</pre></el-collapse-item></el-collapse>
|
||||
<h3>尝试记录</h3><el-timeline v-if="attempts.length"><el-timeline-item v-for="(attempt, index) in attempts" :key="index" :type="attempt.presentation.type" :timestamp="formatTime(attempt.presentation.timestamp)"><strong>{{ attempt.presentation.label }} · {{ attempt.presentation.title }}</strong><p>{{ attempt.presentation.status }}<span v-if="attempt.presentation.duration !== '-'"> · {{ attempt.presentation.duration }}</span><span v-if="attempt.error_message"> · {{ attempt.error_message }}</span></p></el-timeline-item></el-timeline><p v-else class="muted">尚无尝试记录。</p>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
@@ -20,13 +21,14 @@
|
||||
<script>
|
||||
import { listGenerations } from '@/api/chorus'
|
||||
import { contains, dataOf, formatTime } from '../shared'
|
||||
import { attemptPresentation } from './helpers'
|
||||
import { attemptPresentation, generationTiming, kindText } from './helpers'
|
||||
export default {
|
||||
name: 'ChorusGenerations',
|
||||
data() { return { loading: false, rows: [], selected: null, keyword: '', status: '', statuses: ['pending', 'running', 'succeeded', 'failed'] } },
|
||||
computed: { filtered() { return this.rows.filter(row => (!this.keyword || [row.id, row.user_id, row.error_code].some(value => contains(value, this.keyword))) && (!this.status || row.status === this.status)) }, attempts() { return Array.isArray(this.selected && this.selected.attempts) ? this.selected.attempts.map(attempt => ({ ...attempt, presentation: attemptPresentation(attempt) })) : [] } },
|
||||
created() { if (this.$route.query.user) this.keyword = String(this.$route.query.user); this.load() },
|
||||
methods: { formatTime, async load() { this.loading = true; try { this.rows = dataOf(await listGenerations()); if (this.selected) this.selected = this.rows.find(row => row.id === this.selected.id) || null } finally { this.loading = false } }, reset() { this.keyword = ''; this.status = '' }, statusText(value) { return ({ pending: '等待中', running: '处理中', succeeded: '成功', failed: '失败' })[value] || value }, statusType(value) { return ({ pending: 'info', running: 'warning', succeeded: 'success', failed: 'danger' })[value] || 'info' } }
|
||||
data() { return { loading: false, rows: [], selected: null, keyword: '', status: '', statuses: ['pending', 'running', 'succeeded', 'failed'], nowMs: Date.now(), clock: null } },
|
||||
computed: { filtered() { return this.rows.filter(row => (!this.keyword || [row.id, row.user_id, row.username, row.display_name, row.error_code].some(value => contains(value, this.keyword))) && (!this.status || row.status === this.status)) }, attempts() { return Array.isArray(this.selected && this.selected.attempts) ? this.selected.attempts.map(attempt => ({ ...attempt, presentation: attemptPresentation(attempt) })) : [] }, selectedTiming() { return generationTiming(this.selected, this.nowMs) } },
|
||||
created() { if (this.$route.query.user) this.keyword = String(this.$route.query.user); this.load(); this.clock = window.setInterval(() => { this.nowMs = Date.now() }, 1000) },
|
||||
beforeUnmount() { window.clearInterval(this.clock) },
|
||||
methods: { formatTime, kindText, timing(row) { return generationTiming(row, this.nowMs) }, async load() { this.loading = true; try { this.rows = dataOf(await listGenerations()); if (this.selected) this.selected = this.rows.find(row => row.id === this.selected.id) || null } finally { this.loading = false } }, reset() { this.keyword = ''; this.status = '' }, statusText(value) { return ({ pending: '等待中', running: '处理中', succeeded: '成功', failed: '失败' })[value] || value }, statusType(value) { return ({ pending: 'info', running: 'warning', succeeded: 'success', failed: 'danger' })[value] || 'info' } }
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@@ -34,6 +36,6 @@ export default {
|
||||
.generation-grid { display: grid; grid-template-columns: minmax(520px, .9fr) minmax(420px, 1.1fr); gap: 12px; }
|
||||
.generation-detail { min-height: 420px; padding: 16px; border: 1px solid var(--el-border-color-light); background: var(--el-bg-color); }
|
||||
.detail-head { display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--el-border-color-light); padding-bottom: 10px; }
|
||||
.detail-head h2 { margin: 0; font-size: 16px; }.generation-detail h3 { font-size: 15px; margin: 18px 0 12px; }.prompt { white-space: pre-wrap; max-height: 180px; overflow: auto; }.detail-icon { font-size: 42px; }
|
||||
.detail-head h2 { margin: 0; font-size: 16px; }.generation-detail h3 { font-size: 15px; margin: 18px 0 12px; }.prompt-collapse { margin-top: 14px; }.prompt { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; margin: 0; font-family: inherit; }.detail-icon { font-size: 42px; }.numeric { font-variant-numeric: tabular-nums; white-space: nowrap; }.user-name { display: block; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
@media (max-width: 1100px) { .generation-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,12 @@ export function dataOf(response) {
|
||||
|
||||
export function formatTime(value) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (!Number.isFinite(date.getTime())) return '时间异常'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||
}).format(new Date(value))
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function contains(value, keyword) {
|
||||
|
||||
@@ -1,43 +1,58 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { attemptPresentation } from '@/views/chorus/generations/helpers'
|
||||
import { attemptPresentation, durationText, generationTiming, kindText } from '@/views/chorus/generations/helpers'
|
||||
|
||||
describe('Chorus generation attempt presentation', () => {
|
||||
test('renders a lease as task acquisition without a fake model', () => {
|
||||
expect(attemptPresentation({ type: 'lease', started_at: '2026-08-25T09:39:46Z' })).toEqual({
|
||||
title: '任务认领',
|
||||
status: '已认领',
|
||||
type: 'primary',
|
||||
timestamp: '2026-08-25T09:39:46Z'
|
||||
})
|
||||
describe('Chorus generation timing', () => {
|
||||
test('derives terminal, queue, processing, and provider durations', () => {
|
||||
expect(generationTiming({
|
||||
status: 'succeeded',
|
||||
created_at: '2026-08-25T09:00:00Z',
|
||||
started_at: '2026-08-25T09:00:05Z',
|
||||
completed_at: '2026-08-25T09:02:35Z',
|
||||
attempts: [{ type: 'lease', latency_ms: 9000 }, { type: 'provider', latency_ms: 149500 }]
|
||||
})).toEqual({ total: '2 分 35.0 秒', queue: '5.0 秒', processing: '2 分 30.0 秒', upstream: '2 分 29.5 秒' })
|
||||
})
|
||||
|
||||
test('renders an unfinished provider attempt as running', () => {
|
||||
expect(attemptPresentation({ type: 'provider', provider_model_id: 4, started_at: '2026-08-25T09:39:47Z' })).toMatchObject({
|
||||
title: '模型 #4', status: '进行中', type: 'warning'
|
||||
})
|
||||
test('uses now for active work and safely handles missing or invalid timestamps', () => {
|
||||
expect(generationTiming({ status: 'pending', created_at: '2026-08-25T09:00:00Z', attempts: [] }, Date.parse('2026-08-25T09:00:09Z')).total).toBe('9.0 秒')
|
||||
expect(generationTiming({ status: 'failed', created_at: 'bad', completed_at: null }).total).toBe('-')
|
||||
expect(generationTiming({ status: 'succeeded', created_at: '2026-08-25T09:00:05Z', completed_at: '2026-08-25T09:00:00Z' }).total).toBe('时间异常')
|
||||
expect(durationText(-1)).toBe('时间异常')
|
||||
})
|
||||
|
||||
test('renders a finished provider attempt without an error as succeeded', () => {
|
||||
expect(attemptPresentation({ type: 'provider', provider_model_id: 4, started_at: 'start', finished_at: 'finish' })).toEqual({
|
||||
title: '模型 #4', status: '成功', type: 'success', timestamp: 'finish'
|
||||
})
|
||||
})
|
||||
|
||||
test('renders an error as failed even when the event has no finished time', () => {
|
||||
expect(attemptPresentation({ type: 'provider', provider_model_id: 4, error_code: 'route_unavailable', started_at: 'start' })).toEqual({
|
||||
title: '模型 #4', status: 'route_unavailable', type: 'danger', timestamp: 'start'
|
||||
})
|
||||
})
|
||||
|
||||
test('uses a stable fallback for malformed or unknown events', () => {
|
||||
expect(attemptPresentation(null)).toEqual({ title: '系统事件', status: '进行中', type: 'warning', timestamp: '' })
|
||||
expect(attemptPresentation({ type: 'provider' }).title).toBe('模型未记录')
|
||||
})
|
||||
|
||||
test('the page no longer depends on the nonexistent succeeded field', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../../src/views/chorus/generations/index.vue'), 'utf8')
|
||||
expect(source).not.toContain('attempt.succeeded')
|
||||
expect(source).not.toContain("模型 #{{ attempt.provider_model_id || '-' }}")
|
||||
test('translates generation kinds', () => {
|
||||
expect(kindText('image')).toBe('图片')
|
||||
expect(kindText('text')).toBe('文本')
|
||||
expect(kindText('other')).toBe('未知')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chorus generation attempt presentation', () => {
|
||||
test('does not number system events as provider attempts', () => {
|
||||
expect(attemptPresentation({ type: 'lease', started_at: '2026-08-25T09:39:46Z' })).toMatchObject({
|
||||
label: '系统事件', title: '任务认领', status: '已认领', type: 'primary'
|
||||
})
|
||||
})
|
||||
|
||||
test('uses the persisted provider ordinal and enriched names', () => {
|
||||
expect(attemptPresentation({
|
||||
type: 'provider', provider_ordinal: 2, provider_name: '图像服务', model_name: '图像模型',
|
||||
latency_ms: 150000, finished_at: '2026-08-25T09:42:17Z'
|
||||
})).toMatchObject({
|
||||
label: '尝试 2', title: '图像服务 · 图像模型', status: '成功', duration: '2 分 30.0 秒', type: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
test('renders provider errors and stable name fallbacks', () => {
|
||||
expect(attemptPresentation({ type: 'provider', provider_model_id: 4, error_code: 'route_unavailable' })).toMatchObject({
|
||||
label: 'Provider 尝试', title: 'Provider 未记录 · 模型 #4', status: 'route_unavailable', type: 'danger'
|
||||
})
|
||||
expect(attemptPresentation(null)).toMatchObject({ label: '系统事件', title: '系统事件', status: '进行中' })
|
||||
})
|
||||
|
||||
test('the page no longer depends on the nonexistent succeeded field or array index ordinal', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../../src/views/chorus/generations/index.vue'), 'utf8')
|
||||
expect(source).not.toContain('attempt.succeeded')
|
||||
expect(source).not.toContain('尝试 {{ index + 1 }}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,6 +238,7 @@ type generationRow struct {
|
||||
ErrorMessage *string `gorm:"column:error_message"`
|
||||
ProviderAttemptCount uint32 `gorm:"column:provider_attempt_count"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
StartedAt *time.Time `gorm:"column:started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at"`
|
||||
}
|
||||
|
||||
@@ -736,9 +737,74 @@ func (s *Service) Generations(ctx context.Context) ([]GenerationView, error) {
|
||||
if err := s.db.WithContext(ctx).Order("created_at DESC, id DESC").Limit(100).Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("list generations: %w", err)
|
||||
}
|
||||
userIDs := make([]uint64, 0, len(rows))
|
||||
userSeen := make(map[uint64]struct{}, len(rows))
|
||||
modelSeen := make(map[uint64]struct{})
|
||||
parsedAttempts := make(map[uint64][]model.Attempt, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, ok := userSeen[row.UserID]; !ok {
|
||||
userSeen[row.UserID] = struct{}{}
|
||||
userIDs = append(userIDs, row.UserID)
|
||||
}
|
||||
attempts := parseGenerationAttempts(row.Attempts)
|
||||
parsedAttempts[row.ID] = attempts
|
||||
for _, attempt := range attempts {
|
||||
if attempt.ProviderModelID != 0 {
|
||||
modelSeen[attempt.ProviderModelID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
users := make(map[uint64]portalUserRow, len(userIDs))
|
||||
if len(userIDs) > 0 {
|
||||
var userRows []portalUserRow
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", userIDs).Find(&userRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("list generation users: %w", err)
|
||||
}
|
||||
for _, row := range userRows {
|
||||
users[row.ID] = row
|
||||
}
|
||||
}
|
||||
|
||||
modelIDs := make([]uint64, 0, len(modelSeen))
|
||||
for id := range modelSeen {
|
||||
modelIDs = append(modelIDs, id)
|
||||
}
|
||||
models := make(map[uint64]providerModelRow, len(modelIDs))
|
||||
providers := make(map[uint64]providerRow)
|
||||
if len(modelIDs) > 0 {
|
||||
var modelRows []providerModelRow
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", modelIDs).Find(&modelRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("list generation provider models: %w", err)
|
||||
}
|
||||
providerIDs := make([]uint64, 0, len(modelRows))
|
||||
providerSeen := make(map[uint64]struct{}, len(modelRows))
|
||||
for _, row := range modelRows {
|
||||
models[row.ID] = row
|
||||
if _, ok := providerSeen[row.ProviderID]; !ok {
|
||||
providerSeen[row.ProviderID] = struct{}{}
|
||||
providerIDs = append(providerIDs, row.ProviderID)
|
||||
}
|
||||
}
|
||||
if len(providerIDs) > 0 {
|
||||
var providerRows []providerRow
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", providerIDs).Find(&providerRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("list generation providers: %w", err)
|
||||
}
|
||||
for _, row := range providerRows {
|
||||
providers[row.ID] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]GenerationView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item := GenerationView{ID: row.ID, UserID: row.UserID, Status: row.Status, Kind: row.Kind, RenderedPrompt: truncate(row.RenderedPrompt, 1024), Attempts: append(json.RawMessage(nil), row.Attempts...), ProviderAttemptCnt: row.ProviderAttemptCount, CreatedAt: row.CreatedAt, CompletedAt: row.CompletedAt}
|
||||
user := users[row.UserID]
|
||||
username := user.Username
|
||||
if username == "" {
|
||||
username = fmt.Sprintf("user_%d", row.UserID)
|
||||
}
|
||||
item := GenerationView{ID: row.ID, UserID: row.UserID, Username: username, DisplayName: user.DisplayName, Status: row.Status, Kind: row.Kind, RenderedPrompt: truncate(row.RenderedPrompt, 1024), Attempts: enrichGenerationAttempts(parsedAttempts[row.ID], models, providers), ProviderAttemptCnt: row.ProviderAttemptCount, CreatedAt: row.CreatedAt, StartedAt: row.StartedAt, CompletedAt: row.CompletedAt}
|
||||
if row.ErrorCode != nil {
|
||||
item.ErrorCode = *row.ErrorCode
|
||||
}
|
||||
@@ -750,6 +816,27 @@ func (s *Service) Generations(ctx context.Context) ([]GenerationView, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseGenerationAttempts(raw json.RawMessage) []model.Attempt {
|
||||
var attempts []model.Attempt
|
||||
if len(raw) == 0 || json.Unmarshal(raw, &attempts) != nil {
|
||||
return []model.Attempt{}
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
func enrichGenerationAttempts(attempts []model.Attempt, models map[uint64]providerModelRow, providers map[uint64]providerRow) []GenerationAttemptView {
|
||||
items := make([]GenerationAttemptView, 0, len(attempts))
|
||||
for _, attempt := range attempts {
|
||||
item := GenerationAttemptView{Attempt: attempt}
|
||||
if providerModel, ok := models[attempt.ProviderModelID]; ok {
|
||||
item.ModelName = providerModel.Name
|
||||
item.ProviderName = providers[providerModel.ProviderID].Name
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *Service) StartConnectivityCheck(ctx context.Context, actor uint64, requestID string, providerModelID uint64) (ConnectivityCheckView, error) {
|
||||
if providerModelID == 0 {
|
||||
return ConnectivityCheckView{}, FieldError{Field: "provider_model_id", Message: "must be positive"}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package chorus
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
)
|
||||
|
||||
func TestPortalUserViewUsername(t *testing.T) {
|
||||
if got := portalUserView(portalUserRow{ID: 7, Username: "named-user"}).Username; got != "named-user" {
|
||||
@@ -10,3 +15,21 @@ func TestPortalUserViewUsername(t *testing.T) {
|
||||
t.Fatalf("pre-migration username = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationAttemptEnrichmentAndMalformedFallback(t *testing.T) {
|
||||
attempts := parseGenerationAttempts(json.RawMessage(`[{"type":"lease"},{"type":"provider","provider_model_id":4,"provider_ordinal":1}]`))
|
||||
items := enrichGenerationAttempts(attempts, map[uint64]providerModelRow{4: {ID: 4, ProviderID: 2, Name: "图像模型"}}, map[uint64]providerRow{2: {ID: 2, Name: "图像服务"}})
|
||||
if len(items) != 2 || items[0].Type != "lease" {
|
||||
t.Fatalf("events = %#v", items)
|
||||
}
|
||||
if items[1].ProviderName != "图像服务" || items[1].ModelName != "图像模型" || items[1].ProviderOrdinal != 1 {
|
||||
t.Fatalf("provider attempt = %#v", items[1])
|
||||
}
|
||||
if got := parseGenerationAttempts(json.RawMessage(`{"broken":true}`)); len(got) != 0 {
|
||||
t.Fatalf("malformed attempts = %#v", got)
|
||||
}
|
||||
unknown := enrichGenerationAttempts([]model.Attempt{{Type: "provider", ProviderModelID: 99}}, nil, nil)
|
||||
if len(unknown) != 1 || unknown[0].ProviderName != "" || unknown[0].ModelName != "" {
|
||||
t.Fatalf("unknown model fallback = %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-11
@@ -174,17 +174,26 @@ type UserView struct {
|
||||
}
|
||||
|
||||
type GenerationView struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
Status model.GenerationStatus `json:"status"`
|
||||
Kind model.GenerationKind `json:"kind"`
|
||||
RenderedPrompt string `json:"rendered_prompt"`
|
||||
Attempts json.RawMessage `json:"attempts"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ProviderAttemptCnt uint32 `json:"provider_attempt_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status model.GenerationStatus `json:"status"`
|
||||
Kind model.GenerationKind `json:"kind"`
|
||||
RenderedPrompt string `json:"rendered_prompt"`
|
||||
Attempts []GenerationAttemptView `json:"attempts"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ProviderAttemptCnt uint32 `json:"provider_attempt_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
type GenerationAttemptView struct {
|
||||
model.Attempt
|
||||
ProviderName string `json:"provider_name,omitempty"`
|
||||
ModelName string `json:"model_name,omitempty"`
|
||||
}
|
||||
|
||||
type APIKeyFilter struct {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 2fac5b4b6c936d234e63c11ad57aba40777f9d7a
|
||||
synchronized_at: 2026-08-25T08:47:41Z
|
||||
wiki_revision: 2ea61cf082225af867299f24eb6c44661ca7052b
|
||||
synchronized_at: 2026-08-26T03:32:56Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -455,3 +455,14 @@ Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginP
|
||||
- `images` 文生图与 `images_edits` 图片编辑是两个独立协议和能力,不能只改 URL 相互替代;ProviderModel、PromptTemplate、RoutePool 和活动路由必须按 `image_generate` / `image_edit` 分开配置。
|
||||
- `portal/service.ImageCapabilities` 通过活动路由快照向工作台暴露 `image_generate` 与 `image_edit` 可用性;工作台优先选择可用的图片编辑模式、禁用没有活动路由的模式,两种路由都不可用时禁用提交。该页面提示只是前置反馈,提交服务仍执行独立路由校验。
|
||||
<!-- issue-60:end -->
|
||||
|
||||
|
||||
<!-- issue-65:start -->
|
||||
## #65 Generation 时间字段与耗时派生
|
||||
|
||||
- Portal 浏览器 Generation 响应和 OpenAPI Generation schema 均包含可空 `started_at`;`created_at / started_at / completed_at` 统一输出 UTC ISO 8601。浏览器详情的 output 同步返回 `mime_type / size_bytes / width / height / created_at`。
|
||||
- Admin `GenerationView` 返回 `started_at`、终端用户 `username / display_name` 和结构化 attempts。服务层按本批 generation 涉及的用户、ProviderModel、Provider ID 批量查询名称,避免逐行查询;未知引用保留 ID 兜底。
|
||||
- 终态总耗时为 `completed_at-created_at`;非终态总耗时为当前时刻减 `created_at`;排队为 `started_at-created_at`;处理为终态 `completed_at-started_at` 或运行中当前时刻减 `started_at`;上游耗时只汇总 `type=provider` 的 `latency_ms`。
|
||||
- attempts JSON 解析失败时 Admin 返回空时间线而不是使列表接口失败。系统事件不使用数组下标冒充 Provider 尝试序号;Provider 时间线使用持久化的 `provider_ordinal`。
|
||||
- 所有耗时均为响应/界面派生值;本变更不修改 migration、worker、路由、重试、租约或生成状态机。
|
||||
<!-- issue-65:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: 2222a92ff7b1bd7e5ece25ff26b9b39a7f04b310
|
||||
synchronized_at: 2026-08-25T03:22:02Z
|
||||
wiki_revision: 7500d2fdc3c026e3abb5a3cfd7e614cfb6ebd07f
|
||||
synchronized_at: 2026-08-26T03:33:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -305,3 +305,14 @@ MVP-2 的 API Key、OpenAPI v1、三维限流、安全审计、Portal 页面和
|
||||
MVP-2 父工单 #35 仍等待用户单独确认后关闭;本节只记录 #47 跨模块集成验收及其缺陷 #52、#53 已通过。
|
||||
|
||||
<!-- issue-47-mvp2-integration:end -->
|
||||
|
||||
|
||||
<!-- issue-65:start -->
|
||||
## 生成记录耗时与信息边界(#65)
|
||||
|
||||
- Portal 生成历史以“状态 · 耗时 · 相对时间”展示:终态显示固定总耗时,pending 显示实时已等待,running 显示实时已用时;当前结果头部采用相同口径。
|
||||
- Portal 图片结果显示格式、分辨率和文件大小。终端用户侧不展示 Provider、模型、attempt、内部错误码或渲染 Prompt。
|
||||
- Admin 生成记录列表显示用户账号、中文生成类型、总耗时和创建时间;详情派生总耗时、排队耗时、处理耗时与 Provider 上游耗时,并显示提交、开始、完成的本地时间。
|
||||
- Admin 时间线把 lease、本地限流等标记为系统事件,不占 Provider 尝试序号;Provider 尝试显示持久化序号、Provider/模型名称、单次耗时和状态。
|
||||
- 旧记录的缺失、畸形或逆序时间采用安全兜底,不显示负耗时,也不导致页面异常。耗时只从既有时间戳和 `latency_ms` 派生,不持久化重复字段。
|
||||
<!-- issue-65:end -->
|
||||
|
||||
@@ -320,7 +320,7 @@ func (h *Handler) detail(c *gin.Context) {
|
||||
}
|
||||
outputs := make([]gin.H, 0, len(detail.Outputs))
|
||||
for _, output := range detail.Outputs {
|
||||
item := gin.H{"id": output.ID, "kind": output.Kind}
|
||||
item := gin.H{"id": output.ID, "kind": output.Kind, "mime_type": output.MIMEType, "size_bytes": output.SizeBytes, "width": output.Width, "height": output.Height, "created_at": output.CreatedAt.UTC()}
|
||||
if output.TextContent != nil {
|
||||
item["text"] = *output.TextContent
|
||||
}
|
||||
@@ -405,7 +405,15 @@ func (h *Handler) writeOutput(c *gin.Context, thumbnail bool) {
|
||||
}
|
||||
|
||||
func generationResponse(generation model.Generation) gin.H {
|
||||
return gin.H{"id": generation.ID, "kind": generation.Kind, "status": generation.Status, "terminal": generation.Status.Terminal(), "user_prompt": generation.UserPrompt, "error_code": generation.ErrorCode, "error_message": generation.ErrorMessage, "created_at": generation.CreatedAt, "completed_at": generation.CompletedAt}
|
||||
return gin.H{"id": generation.ID, "kind": generation.Kind, "status": generation.Status, "terminal": generation.Status.Terminal(), "user_prompt": generation.UserPrompt, "error_code": generation.ErrorCode, "error_message": generation.ErrorMessage, "created_at": generation.CreatedAt.UTC(), "started_at": utcTimePointer(generation.StartedAt), "completed_at": utcTimePointer(generation.CompletedAt)}
|
||||
}
|
||||
|
||||
func utcTimePointer(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
utc := value.UTC()
|
||||
return &utc
|
||||
}
|
||||
func (h *Handler) serviceError(c *gin.Context, err error) {
|
||||
var fileError *service.FileError
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
)
|
||||
|
||||
func TestSafeReturnTo(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -21,3 +26,20 @@ func TestSafeReturnTo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationResponseUsesUTCAndIncludesStartedAt(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*60*60)
|
||||
created := time.Date(2026, 8, 25, 17, 0, 0, 0, location)
|
||||
started := created.Add(5 * time.Second)
|
||||
response := generationResponse(model.Generation{ID: 7, Status: model.StatusRunning, CreatedAt: created, StartedAt: &started})
|
||||
if got := response["created_at"].(time.Time); got.Location() != time.UTC || got.Hour() != 9 {
|
||||
t.Fatalf("created_at = %#v", got)
|
||||
}
|
||||
gotStarted, ok := response["started_at"].(*time.Time)
|
||||
if !ok || gotStarted == nil || gotStarted.Location() != time.UTC || gotStarted.Hour() != 9 {
|
||||
t.Fatalf("started_at = %#v", response["started_at"])
|
||||
}
|
||||
if response["completed_at"] != (*time.Time)(nil) {
|
||||
t.Fatalf("completed_at = %#v", response["completed_at"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"error_code": {"type": ["string", "null"]},
|
||||
"error_message": {"type": ["string", "null"]},
|
||||
"created_at": {"type": "string", "format": "date-time"},
|
||||
"started_at": {"type": ["string", "null"], "format": "date-time"},
|
||||
"completed_at": {"type": ["string", "null"], "format": "date-time"}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -63,3 +63,22 @@ func TestSpecReturnsDefensiveCopy(t *testing.T) {
|
||||
t.Fatal("Spec returned mutable embedded storage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationSchemaIncludesNullableStartedAt(t *testing.T) {
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal(Spec(), &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
components := document["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
generation := schemas["Generation"].(map[string]any)
|
||||
properties := generation["properties"].(map[string]any)
|
||||
startedAt, ok := properties["started_at"].(map[string]any)
|
||||
if !ok || startedAt["format"] != "date-time" {
|
||||
t.Fatalf("started_at schema = %#v", properties["started_at"])
|
||||
}
|
||||
types, ok := startedAt["type"].([]any)
|
||||
if !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
|
||||
t.Fatalf("started_at type = %#v", startedAt["type"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,8 @@ func setRunning(db *gorm.DB, generationID uint64, email string) error {
|
||||
}
|
||||
result := db.Model(&model.Generation{}).Where("id=? AND status=?", generationID, model.StatusPending).Updates(map[string]any{
|
||||
"status": model.StatusRunning, "lease_owner": "e2e-fixture", "lease_token": fixtureLeaseToken,
|
||||
"lease_until": time.Now().Add(5 * time.Minute), "attempt_count": 1,
|
||||
"attempts": gorm.Expr("JSON_ARRAY(JSON_OBJECT('attempt',1,'started_at',NOW(6),'latency_ms',1))"),
|
||||
"lease_until": time.Now().Add(5 * time.Minute), "started_at": time.Now(), "attempt_count": 1,
|
||||
"attempts": gorm.Expr("JSON_ARRAY(JSON_OBJECT('type','lease','started_at',NOW(6)))"),
|
||||
})
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
return fmt.Errorf("set fixture generation running")
|
||||
@@ -234,9 +234,10 @@ func setImageSuccess(db *gorm.DB, generationID uint64, email string) error {
|
||||
}()
|
||||
mimeType := objects.Original.ContentType
|
||||
size := uint64(objects.Original.Size)
|
||||
width, height := uint32(4), uint32(4)
|
||||
originalKey, thumbnailKey := objects.Original.Key, objects.Thumbnail.Key
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
output := model.GenerationOutput{GenerationID: generationID, Kind: model.KindImage, StorageKey: &originalKey, ThumbnailStorageKey: &thumbnailKey, MIMEType: &mimeType, SizeBytes: &size}
|
||||
output := model.GenerationOutput{GenerationID: generationID, Kind: model.KindImage, StorageKey: &originalKey, ThumbnailStorageKey: &thumbnailKey, MIMEType: &mimeType, SizeBytes: &size, Width: &width, Height: &height}
|
||||
if err := tx.Create(&output).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -192,9 +192,17 @@ test("browser submits image generation and ordered image editing", async ({ page
|
||||
fixture("running", generatedID);
|
||||
await page.reload();
|
||||
await expect(page.getByText("正在生成内容")).toBeVisible();
|
||||
await expect(page.locator("#result-section .generation-duration")).toContainText("已用时");
|
||||
fixture("image-success", generatedID);
|
||||
await expect(page.locator(".result-gallery img")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator("#result-section")).not.toHaveAttribute("hx-get");
|
||||
await expect(page.locator("#result-section .generation-duration")).toContainText("总耗时");
|
||||
await expect(page.locator(".result-meta")).toContainText("PNG · 4 x 4");
|
||||
const generatedDetail = await (await page.request.get(`/api/generations/${generatedID}`)).json();
|
||||
expect(generatedDetail.started_at).toBeTruthy();
|
||||
expect(generatedDetail.outputs[0]).toMatchObject({ mime_type: "image/png", width: 4, height: 4 });
|
||||
expect(generatedDetail.outputs[0].size_bytes).toBeGreaterThan(0);
|
||||
expect(generatedDetail.outputs[0].created_at).toBeTruthy();
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator('[data-mode="edit"]').click();
|
||||
|
||||
@@ -371,13 +371,45 @@
|
||||
}
|
||||
|
||||
function relativeTime(value) {
|
||||
const elapsed = Math.max(0, Date.now() - new Date(value).getTime());
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (!Number.isFinite(timestamp)) return "时间未知";
|
||||
const elapsed = Math.max(0, Date.now() - timestamp);
|
||||
if (elapsed < 60000) return "刚刚";
|
||||
if (elapsed < 3600000) return `${Math.floor(elapsed / 60000)} 分钟前`;
|
||||
if (elapsed < 86400000) return `${Math.floor(elapsed / 3600000)} 小时前`;
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds) {
|
||||
if (!Number.isFinite(milliseconds) || milliseconds < 0) return "时间异常";
|
||||
if (milliseconds < 1000) return "<1秒";
|
||||
let seconds = Math.round(milliseconds / 1000);
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
seconds %= 60;
|
||||
if (minutes < 60) return `${minutes}分${String(seconds).padStart(2, "0")}秒`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}小时${String(minutes % 60).padStart(2, "0")}分${String(seconds).padStart(2, "0")}秒`;
|
||||
}
|
||||
|
||||
function generationDuration(status, createdAt, completedAt, context = "history", now = Date.now()) {
|
||||
const created = new Date(createdAt).getTime();
|
||||
const terminal = status === "succeeded" || status === "failed";
|
||||
const completed = completedAt ? new Date(completedAt).getTime() : NaN;
|
||||
if (!Number.isFinite(created) || (terminal && !Number.isFinite(completed))) return "耗时未知";
|
||||
const value = formatDuration((terminal ? completed : now) - created);
|
||||
if (value === "时间异常") return value;
|
||||
if (terminal) return context === "result" ? `总耗时 ${value}` : value;
|
||||
return `${status === "pending" ? "已等待" : "已用时"} ${value}`;
|
||||
}
|
||||
|
||||
function updateTimes(root = document) {
|
||||
root.querySelectorAll?.(".generation-duration").forEach(element => {
|
||||
element.textContent = generationDuration(element.dataset.status, element.dataset.createdAt, element.dataset.completedAt, element.dataset.durationContext);
|
||||
});
|
||||
root.querySelectorAll?.("time.relative-time").forEach(element => { element.textContent = relativeTime(element.dateTime); });
|
||||
}
|
||||
|
||||
function historyItem(row) {
|
||||
const item = document.createElement("li");
|
||||
item.dataset.historySearch = `${row.user_prompt || ""} ${row.kind || ""}`;
|
||||
@@ -386,6 +418,8 @@
|
||||
link.href = `/generations/${row.id}`;
|
||||
const iconWrap = document.createElement("span");
|
||||
iconWrap.className = "history-icon";
|
||||
iconWrap.setAttribute("aria-label", row.kind === "image" ? "图片" : "文本");
|
||||
iconWrap.title = row.kind === "image" ? "图片" : "文本";
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "icon";
|
||||
icon.dataset.lucide = row.kind === "image" ? "image" : "file-text";
|
||||
@@ -400,7 +434,18 @@
|
||||
meta.className = "history-meta";
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `status-dot ${row.status === "succeeded" ? "success" : row.status || ""}`;
|
||||
meta.append(dot, document.createTextNode(`${statusText(row.status)} · ${row.kind === "image" ? "图片" : "文本"} · ${relativeTime(row.created_at)}`));
|
||||
const duration = document.createElement("span");
|
||||
duration.className = "generation-duration";
|
||||
duration.dataset.durationContext = "history";
|
||||
duration.dataset.status = row.status || "";
|
||||
duration.dataset.createdAt = row.created_at || "";
|
||||
duration.dataset.completedAt = row.completed_at || "";
|
||||
duration.textContent = generationDuration(row.status, row.created_at, row.completed_at);
|
||||
const relative = document.createElement("time");
|
||||
relative.className = "relative-time";
|
||||
relative.dateTime = row.created_at || "";
|
||||
relative.textContent = relativeTime(row.created_at);
|
||||
meta.append(dot, document.createTextNode(`${statusText(row.status)} · `), duration, document.createTextNode(" · "), relative);
|
||||
copy.append(title, meta);
|
||||
link.append(iconWrap, copy);
|
||||
item.append(link);
|
||||
@@ -538,7 +583,7 @@
|
||||
document.querySelector("#dismiss-auth")?.addEventListener("click", () => hide(document.querySelector("#auth-modal")));
|
||||
document.querySelector("#relogin-button")?.addEventListener("click", () => window.location.assign(`/login?return_to=${encodeURIComponent(window.location.pathname)}`));
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape") { hide(document.querySelector("#auth-modal")); document.querySelector("#main-content")?.focus(); } });
|
||||
document.body.addEventListener("htmx:afterSwap", event => renderIcons(event.detail.target));
|
||||
document.body.addEventListener("htmx:afterSwap", event => { renderIcons(event.detail.target); updateTimes(event.detail.target); });
|
||||
const handlePollingError = event => {
|
||||
const status = event.detail.xhr?.status || 0; const element = event.detail.elt;
|
||||
if (status === 401) { showAuthExpired(); return; }
|
||||
@@ -547,6 +592,8 @@
|
||||
document.body.addEventListener("htmx:responseError", handlePollingError);
|
||||
document.body.addEventListener("htmx:sendError", handlePollingError);
|
||||
updatePromptPreview();
|
||||
updateTimes();
|
||||
window.setInterval(() => updateTimes(), 1000);
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", initializeAlpine);
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
{{if .History}}<ul class="history-list" id="history-list" aria-live="polite">
|
||||
{{range .History}}<li data-history-search="{{.UserPrompt}} {{kindLabel .Kind}}">
|
||||
<a class="history-item" href="/generations/{{.ID}}" {{if $.Current}}{{if eq .ID $.Current.Generation.ID}}aria-current="page"{{end}}{{end}}>
|
||||
<span class="history-icon"><i class="icon" data-lucide="{{kindIcon .Kind}}"></i></span>
|
||||
<span class="history-copy"><span class="history-title" title="{{.UserPrompt}}">{{shortPrompt .UserPrompt}}</span><span class="history-meta"><span class="status-dot {{statusTone .Status}}"></span>{{statusLabel .Status}} · {{kindLabel .Kind}} · {{relativeTime .CreatedAt}}</span></span>
|
||||
<span class="history-icon" aria-label="{{kindLabel .Kind}}" title="{{kindLabel .Kind}}"><i class="icon" data-lucide="{{kindIcon .Kind}}"></i></span>
|
||||
<span class="history-copy"><span class="history-title" title="{{.UserPrompt}}">{{shortPrompt .UserPrompt}}</span><span class="history-meta"><span class="status-dot {{statusTone .Status}}"></span>{{statusLabel .Status}} · <span class="generation-duration" data-duration-context="history" data-status="{{.Status}}" data-created-at="{{.CreatedAt.UTC.Format "2006-01-02T15:04:05.999999999Z07:00"}}" data-completed-at="{{timeAttr .CompletedAt}}">{{historyDuration .}}</span> · <time class="relative-time" datetime="{{.CreatedAt.UTC.Format "2006-01-02T15:04:05.999999999Z07:00"}}">{{relativeTime .CreatedAt}}</time></span></span>
|
||||
</a>
|
||||
</li>{{end}}<li class="history-sentinel" id="history-sentinel" aria-hidden="true"></li>
|
||||
</ul>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{{define "result"}}<div id="result-section" {{if .Current}}{{if not (terminal .Current.Generation.Status)}}hx-get="/ui/generations/{{.Current.Generation.ID}}/result" hx-trigger="load delay:1200ms, retryPoll" hx-swap="outerHTML"{{end}}{{end}}>
|
||||
<div class="panel-header"><div class="panel-heading-group"><h2 id="result-title">当前结果</h2><p>{{if .Current}}任务 #{{.Current.Generation.ID}} · {{kindLabel .Current.Generation.Kind}}{{else}}填写要求后开始生成{{end}}</p></div>{{if .Current}}<span class="status-badge" data-tone="{{statusTone .Current.Generation.Status}}"><span class="status-dot {{statusTone .Current.Generation.Status}}"></span>{{statusLabel .Current.Generation.Status}}</span>{{end}}</div>
|
||||
<div class="panel-header"><div class="panel-heading-group"><h2 id="result-title">当前结果</h2><p>{{if .Current}}任务 #{{.Current.Generation.ID}} · {{kindLabel .Current.Generation.Kind}} · <span class="generation-duration" data-duration-context="result" data-status="{{.Current.Generation.Status}}" data-created-at="{{.Current.Generation.CreatedAt.UTC.Format "2006-01-02T15:04:05.999999999Z07:00"}}" data-completed-at="{{timeAttr .Current.Generation.CompletedAt}}">{{resultDuration .Current.Generation}}</span>{{else}}填写要求后开始生成{{end}}</p></div>{{if .Current}}<span class="status-badge" data-tone="{{statusTone .Current.Generation.Status}}"><span class="status-dot {{statusTone .Current.Generation.Status}}"></span>{{statusLabel .Current.Generation.Status}}</span>{{end}}</div>
|
||||
<div class="result-body" aria-live="polite">
|
||||
{{if not .Current}}<div class="state-message"><span class="state-icon"><i class="icon" data-lucide="plus"></i></span><h3>开始第一次生成</h3><p>选择文本或图片,填写要求后提交。任务状态和结果会显示在这里。</p></div>
|
||||
{{else if eq .Current.Generation.Status "pending"}}<div class="state-message"><span class="state-icon"><i class="icon" data-lucide="clock-3"></i></span><h3>任务正在排队</h3><p>请求已经保存,尚未开始执行。可以离开当前页面,稍后从历史记录返回。</p></div>
|
||||
{{else if eq .Current.Generation.Status "running"}}<div class="state-message"><span class="state-icon"><i class="icon" data-lucide="refresh-cw"></i></span><h3>正在生成内容</h3><p>任务已经开始执行。页面会自动更新状态,不需要重复提交。</p><div class="progress-track" aria-label="生成进行中"><div class="progress-bar"></div></div></div>
|
||||
{{else if eq .Current.Generation.Status "failed"}}<div class="state-message"><span class="state-icon danger"><i class="icon" data-lucide="alert-circle"></i></span><h3>这次生成没有完成</h3><p>{{if .Current.Generation.ErrorMessage}}{{value .Current.Generation.ErrorMessage}}{{else}}生成服务暂时不可用,输入已经保留。{{end}}</p><button class="button button-primary retry-button" type="button" data-generation-id="{{.Current.Generation.ID}}"><i class="icon" data-lucide="refresh-cw"></i>再次生成</button></div>
|
||||
{{else if eq .Current.Generation.Kind "text"}}<div><article class="text-result">{{range .Current.Outputs}}{{if .TextContent}}<p>{{value .TextContent}}</p>{{end}}{{end}}</article><div class="result-actions"><span class="result-meta">文本结果</span><div class="action-group"><button class="button button-secondary retry-button" type="button" data-generation-id="{{.Current.Generation.ID}}"><i class="icon" data-lucide="refresh-cw"></i>再次生成</button><button class="button button-primary copy-button" type="button"><i class="icon" data-lucide="copy"></i>复制</button></div></div></div>
|
||||
{{else}}<div class="result-gallery">{{range .Current.Outputs}}{{if .StorageKey}}<div><a class="result-frame" href="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" target="_blank" rel="noopener" aria-label="查看生成图片"><img src="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" alt="生成图片结果" loading="eager"></a><div class="result-actions"><span class="result-meta">{{if .MIMEType}}{{value .MIMEType}}{{else}}图片结果{{end}}</span><div class="action-group"><button class="button button-secondary retry-button" type="button" data-generation-id="{{$.Current.Generation.ID}}"><i class="icon" data-lucide="refresh-cw"></i>再次生成</button><a class="button button-primary" href="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" download><i class="icon" data-lucide="download"></i>下载</a></div></div></div>{{end}}{{end}}</div>{{end}}
|
||||
{{else}}<div class="result-gallery">{{range .Current.Outputs}}{{if .StorageKey}}<div><a class="result-frame" href="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" target="_blank" rel="noopener" aria-label="查看生成图片"><img src="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" alt="生成图片结果" loading="eager"></a><div class="result-actions"><span class="result-meta">{{outputMetadata .}}</span><div class="action-group"><button class="button button-secondary retry-button" type="button" data-generation-id="{{$.Current.Generation.ID}}"><i class="icon" data-lucide="refresh-cw"></i>再次生成</button><a class="button button-primary" href="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" download><i class="icon" data-lucide="download"></i>下载</a></div></div></div>{{end}}{{end}}</div>{{end}}
|
||||
{{if .Current}}{{range .Current.Inputs}}<span class="retry-input sr-only" data-input-url="/api/generations/{{$.Current.Generation.ID}}/inputs/{{.ID}}" data-input-name="{{.OriginalName}}" data-input-mime="{{.MIMEType}}" data-input-role="{{.Role}}" data-input-note="{{value .Note}}"></span>{{end}}<span class="retry-data sr-only" data-kind="{{.Current.Generation.Kind}}" data-prompt="{{.Current.Generation.UserPrompt}}" data-role-rule="{{value .Current.Generation.RoleRule}}"></span>{{end}}
|
||||
</div>
|
||||
</div>{{end}}
|
||||
|
||||
@@ -39,6 +39,10 @@ func NewRenderer() (*Renderer, error) {
|
||||
functions := template.FuncMap{
|
||||
"statusLabel": statusLabel, "statusTone": statusTone, "kindLabel": kindLabel, "kindIcon": kindIcon,
|
||||
"shortPrompt": shortPrompt, "relativeTime": relativeTime, "terminal": func(status model.GenerationStatus) bool { return status.Terminal() },
|
||||
"historyDuration": func(g model.Generation) string { return generationDuration(g, time.Now(), false) },
|
||||
"resultDuration": func(g model.Generation) string { return generationDuration(g, time.Now(), true) },
|
||||
"timeAttr": optionalTimeAttr,
|
||||
"outputMetadata": outputMetadata,
|
||||
"value": func(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
@@ -117,3 +121,79 @@ func relativeTime(value time.Time) string {
|
||||
return value.Local().Format("01-02 15:04")
|
||||
}
|
||||
}
|
||||
|
||||
func generationDuration(generation model.Generation, now time.Time, includeLabel bool) string {
|
||||
label := "总耗时"
|
||||
end := now
|
||||
if !generation.Status.Terminal() {
|
||||
if generation.Status == model.StatusPending {
|
||||
label = "已等待"
|
||||
} else {
|
||||
label = "已用时"
|
||||
}
|
||||
} else if generation.CompletedAt != nil {
|
||||
end = *generation.CompletedAt
|
||||
} else {
|
||||
return "耗时未知"
|
||||
}
|
||||
if generation.CreatedAt.IsZero() || end.Before(generation.CreatedAt) {
|
||||
return "时间异常"
|
||||
}
|
||||
value := formatDuration(end.Sub(generation.CreatedAt))
|
||||
if includeLabel || !generation.Status.Terminal() {
|
||||
return label + " " + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func formatDuration(duration time.Duration) string {
|
||||
if duration < time.Second {
|
||||
return "<1秒"
|
||||
}
|
||||
seconds := int64(duration.Round(time.Second) / time.Second)
|
||||
if seconds < 60 {
|
||||
return fmt.Sprintf("%d秒", seconds)
|
||||
}
|
||||
minutes := seconds / 60
|
||||
seconds %= 60
|
||||
if minutes < 60 {
|
||||
return fmt.Sprintf("%d分%02d秒", minutes, seconds)
|
||||
}
|
||||
hours := minutes / 60
|
||||
minutes %= 60
|
||||
return fmt.Sprintf("%d小时%02d分%02d秒", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
func optionalTimeAttr(value *time.Time) string {
|
||||
if value == nil || value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func outputMetadata(output model.GenerationOutput) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if output.MIMEType != nil && strings.TrimSpace(*output.MIMEType) != "" {
|
||||
parts = append(parts, strings.ToUpper(strings.TrimPrefix(strings.TrimSpace(*output.MIMEType), "image/")))
|
||||
}
|
||||
if output.Width != nil && output.Height != nil && *output.Width > 0 && *output.Height > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d x %d", *output.Width, *output.Height))
|
||||
}
|
||||
if output.SizeBytes != nil {
|
||||
parts = append(parts, formatBytes(*output.SizeBytes))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "图片结果"
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func formatBytes(bytes uint64) string {
|
||||
if bytes < 1024 {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
if bytes < 1024*1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
)
|
||||
|
||||
func TestLoginUsesAccountOnly(t *testing.T) {
|
||||
@@ -46,6 +49,54 @@ func TestLoginUsesAccountOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationDurationUsesTerminalAndLiveBoundaries(t *testing.T) {
|
||||
created := time.Date(2026, 8, 25, 9, 0, 0, 0, time.UTC)
|
||||
completed := created.Add(2*time.Minute + 30*time.Second)
|
||||
terminal := model.Generation{Status: model.StatusSucceeded, CreatedAt: created, CompletedAt: &completed}
|
||||
if got := generationDuration(terminal, completed.Add(time.Hour), false); got != "2分30秒" {
|
||||
t.Fatalf("terminal duration = %q", got)
|
||||
}
|
||||
pending := model.Generation{Status: model.StatusPending, CreatedAt: created}
|
||||
if got := generationDuration(pending, created.Add(9*time.Second), true); got != "已等待 9秒" {
|
||||
t.Fatalf("pending duration = %q", got)
|
||||
}
|
||||
invalid := model.Generation{Status: model.StatusSucceeded, CreatedAt: completed, CompletedAt: &created}
|
||||
if got := generationDuration(invalid, completed, true); got != "时间异常" {
|
||||
t.Fatalf("invalid duration = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputMetadataUsesAvailableFields(t *testing.T) {
|
||||
mimeType, size, width, height := "image/png", uint64(1536), uint32(1024), uint32(768)
|
||||
output := model.GenerationOutput{MIMEType: &mimeType, SizeBytes: &size, Width: &width, Height: &height}
|
||||
if got := outputMetadata(output); got != "PNG · 1024 x 768 · 1.5 KB" {
|
||||
t.Fatalf("metadata = %q", got)
|
||||
}
|
||||
if got := outputMetadata(model.GenerationOutput{}); got != "图片结果" {
|
||||
t.Fatalf("fallback metadata = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRendersHistoryDurationMarkup(t *testing.T) {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created := time.Now().Add(-10 * time.Second)
|
||||
completed := created.Add(5 * time.Second)
|
||||
var page bytes.Buffer
|
||||
data := Page{History: []model.Generation{{ID: 3, Status: model.StatusSucceeded, Kind: model.KindImage, UserPrompt: "test", CreatedAt: created, CompletedAt: &completed}}, MaxPromptBytes: 1000, MaxImages: 3}
|
||||
if err := renderer.Render(&page, "app", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := page.String()
|
||||
for _, expected := range []string{`aria-label="图片"`, `data-duration-context="history"`, `>5秒</span>`, `class="relative-time"`} {
|
||||
if !strings.Contains(html, expected) {
|
||||
t.Errorf("history markup is missing %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDisablesUnavailableImageGenerateRoute(t *testing.T) {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user