feat: add per-user-message work timer with persistence
This commit is contained in:
parent
de1aebeaa8
commit
2700a25702
@ -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,
|
||||
|
||||
@ -165,7 +165,8 @@ export const historyMethods = {
|
||||
role: 'user',
|
||||
content: message.content || '',
|
||||
images,
|
||||
videos
|
||||
videos,
|
||||
metadata: message.metadata || {}
|
||||
});
|
||||
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user