feat: add per-user-message work timer with persistence

This commit is contained in:
JOJO 2026-04-05 15:14:29 +08:00
parent de1aebeaa8
commit 2700a25702
6 changed files with 164 additions and 5 deletions

View File

@ -462,9 +462,61 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
state["suppress_next"] = False
# 添加到对话历史
user_work_started_at = datetime.now().isoformat()
user_message_index = -1
user_work_finalized = False
history_len_before = len(getattr(web_terminal.context_manager, "conversation_history", []) or [])
is_first_user_message = history_len_before == 0
web_terminal.context_manager.add_conversation("user", message, images=images, videos=videos)
web_terminal.context_manager.add_conversation(
"user",
message,
images=images,
videos=videos,
metadata={
"work_timer": {
"status": "working",
"started_at": user_work_started_at
}
}
)
try:
user_message_index = len(getattr(web_terminal.context_manager, "conversation_history", []) or []) - 1
except Exception:
user_message_index = -1
def finalize_user_work_timer():
nonlocal user_work_finalized
if user_work_finalized:
return
history = getattr(web_terminal.context_manager, "conversation_history", None) or []
if user_message_index < 0 or user_message_index >= len(history):
return
target_msg = history[user_message_index] or {}
if target_msg.get("role") != "user":
return
metadata = target_msg.get("metadata") or {}
timer = metadata.get("work_timer")
if not isinstance(timer, dict):
timer = {}
started_at = timer.get("started_at") or target_msg.get("timestamp") or user_work_started_at
start_ts = None
try:
start_ts = datetime.fromisoformat(str(started_at).replace("Z", "+00:00")).timestamp()
except Exception:
start_ts = None
now_ts = time.time()
duration_ms = int(max(0.0, (now_ts - start_ts) * 1000.0)) if start_ts is not None else 0
timer.update({
"status": "completed",
"started_at": started_at,
"finished_at": datetime.now().isoformat(),
"duration_ms": duration_ms
})
metadata["work_timer"] = timer
target_msg["metadata"] = metadata
history[user_message_index] = target_msg
web_terminal.context_manager.auto_save_conversation(force=True)
user_work_finalized = True
# Skill 提示系统:检测关键词并在用户消息之后插入 system 消息
try:
@ -535,6 +587,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
'status_code': 400,
'error_type': 'context_overflow'
})
finalize_user_work_timer()
return
usage_percent = (current_tokens / max_context_tokens) * 100
warned = web_terminal.context_manager.conversation_metadata.get("context_warning_sent", False)
@ -649,6 +702,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
'message': "配额已达到上限,暂时无法继续调用模型。",
'quota': quota_info
})
finalize_user_work_timer()
return
tool_call_limit_label = MAX_TOTAL_TOOL_CALLS if MAX_TOTAL_TOOL_CALLS is not None else ""
@ -687,6 +741,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
accumulated_response=accumulated_response,
)
if stream_result.get("stopped"):
finalize_user_work_timer()
return
full_response = stream_result["full_response"]
@ -859,6 +914,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
)
last_tool_call_time = tool_loop_result.get("last_tool_call_time", last_tool_call_time)
if tool_loop_result.get("stopped"):
finalize_user_work_timer()
return
# 标记不再是第一次迭代
@ -923,6 +979,8 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
socketio.start_background_task(run_poll)
# 发送完成事件(如果有子智能体在运行,前端会保持等待状态)
if not has_running_sub_agents:
finalize_user_work_timer()
sender('task_complete', {
'total_iterations': total_iterations,
'total_tool_calls': total_tool_calls,

View File

@ -165,7 +165,8 @@ export const historyMethods = {
role: 'user',
content: message.content || '',
images,
videos
videos,
metadata: message.metadata || {}
});
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');

View File

@ -653,6 +653,34 @@ export const taskPollingMethods = {
this.conditionalScrollToBottom();
},
markLatestUserWorkCompleted() {
if (!Array.isArray(this.messages) || this.messages.length === 0) {
return;
}
for (let i = this.messages.length - 1; i >= 0; i -= 1) {
const msg = this.messages[i];
if (!msg || msg.role !== 'user') {
continue;
}
msg.metadata = msg.metadata || {};
msg.metadata.work_timer = msg.metadata.work_timer || {};
const timer = msg.metadata.work_timer;
if (timer.status === 'completed' && typeof timer.duration_ms === 'number') {
return;
}
const nowIso = new Date().toISOString();
const startedAt = timer.started_at || msg.timestamp || nowIso;
const startMs = Date.parse(startedAt);
const endMs = Date.now();
const durationMs = Number.isFinite(startMs) ? Math.max(0, endMs - startMs) : 0;
timer.status = 'completed';
timer.started_at = startedAt;
timer.finished_at = nowIso;
timer.duration_ms = durationMs;
return;
}
},
handleTaskComplete(data: any) {
const hasRunningSubAgents = !!data?.has_running_sub_agents;
if (hasRunningSubAgents) {
@ -664,6 +692,9 @@ export const taskPollingMethods = {
// 同步处理状态更新
this.streamingMessage = false;
this.stopRequested = false;
if (!hasRunningSubAgents) {
this.markLatestUserWorkCompleted();
}
if (hasRunningSubAgents) {
this.taskInProgress = true;
@ -688,6 +719,7 @@ export const taskPollingMethods = {
handleTaskStopped(data: any, eventIdx: number) {
debugLog('[TaskPolling] 任务已停止, idx:', eventIdx, data);
this.markLatestUserWorkCompleted();
this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false;
@ -790,6 +822,7 @@ export const taskPollingMethods = {
});
// 清理状态
this.markLatestUserWorkCompleted();
this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false;

View File

@ -27,6 +27,7 @@
<div v-if="index > 0 && filteredMessages[index - 1].role === 'user' && (msg.actions?.length > 0 || msg.awaitingFirstContent)" class="message-header icon-label">
<span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span>
<span>{{ aiAssistantName }}</span>
<span v-if="assistantWorkLabel(index)" class="assistant-work-status">{{ assistantWorkLabel(index) }}</span>
</div>
<div
v-if="msg.awaitingFirstContent"
@ -379,7 +380,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue';
@ -426,6 +427,8 @@ const filteredMessages = computed(() =>
(props.messages || []).filter(m => !(m && m.metadata && m.metadata.system_injected_image) && m.role !== 'system')
);
const latestMessageIndex = computed(() => filteredMessages.value.length - 1);
const nowMs = ref(Date.now());
let timerHandle: number | null = null;
const DEFAULT_GENERATING_TEXT = '生成中…';
const rootEl = ref<HTMLElement | null>(null);
@ -472,6 +475,56 @@ function getPreviewUrl(path: string): string {
return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
}
function formatDurationMs(durationMs: number): string {
const totalSeconds = Math.max(0, Math.floor((durationMs || 0) / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
function assistantWorkLabel(index: number): string {
if (index <= 0) {
return '';
}
const prev = filteredMessages.value[index - 1];
if (!prev || prev.role !== 'user') {
return '';
}
const timer = prev?.metadata?.work_timer;
if (!timer || !timer.started_at) {
return '';
}
const startMs = Date.parse(timer.started_at);
if (!Number.isFinite(startMs)) {
return '';
}
const status = timer.status || 'working';
if (status === 'working') {
return `工作中 ${formatDurationMs(nowMs.value - startMs)}`;
}
const durationMs = typeof timer.duration_ms === 'number'
? timer.duration_ms
: Math.max(0, (Number.isFinite(Date.parse(timer.finished_at || '')) ? Date.parse(timer.finished_at) : nowMs.value) - startMs);
return `工作完成 ${formatDurationMs(durationMs)}`;
}
onMounted(() => {
timerHandle = window.setInterval(() => {
nowMs.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (timerHandle !== null) {
clearInterval(timerHandle);
timerHandle = null;
}
});
const isStackable = (action: any) => action && (action.type === 'thinking' || action.type === 'tool');
const isEmptyTextAction = (action: any) => {
if (!action || action.type !== 'text') {

View File

@ -165,11 +165,18 @@ export const useChatStore = defineStore('chat', {
return message;
},
addUserMessage(content: string, images: string[] = [], videos: string[] = []) {
const startedAt = new Date().toISOString();
this.messages.push({
role: 'user',
content,
images,
videos
videos,
metadata: {
work_timer: {
status: 'working',
started_at: startedAt
}
}
});
this.currentMessageIndex = -1;
},

View File

@ -114,7 +114,7 @@
.scroll-lock-toggle {
position: fixed;
right: 8px;
bottom: 140px;
bottom: 100px;
z-index: 25;
display: flex;
align-items: center;
@ -378,6 +378,13 @@
letter-spacing: 0.02em;
}
.assistant-work-status {
margin-left: 8px;
font-size: 12px;
font-weight: 500;
color: var(--claude-text-secondary);
}
.user-message .message-text,
.assistant-message .message-text {
padding: 16px 20px;