feat: 完善生成记录耗时展示 (#65)

This commit is contained in:
ila
2026-08-26 11:36:56 +08:00
parent b6c8d6be5d
commit 762743c323
20 changed files with 526 additions and 78 deletions
@@ -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'
+13 -11
View File
@@ -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>
+3 -1
View File
@@ -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 }}')
})
})
+88 -1
View File
@@ -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"}
+24 -1
View File
@@ -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
View File
@@ -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 {
+13 -2
View File
@@ -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 -->
+13 -2
View File
@@ -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 -->
+10 -2
View File
@@ -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
+23 -1
View File
@@ -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"])
}
}
+1
View File
@@ -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"}
}
},
+19
View File
@@ -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"])
}
}
+4 -3
View File
@@ -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
}
+8
View File
@@ -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();
+50 -3
View File
@@ -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);
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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}}
+80
View File
@@ -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))
}
+51
View File
@@ -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 {