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
|
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 [])
|
history_len_before = len(getattr(web_terminal.context_manager, "conversation_history", []) or [])
|
||||||
is_first_user_message = history_len_before == 0
|
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 消息
|
# Skill 提示系统:检测关键词并在用户消息之后插入 system 消息
|
||||||
try:
|
try:
|
||||||
@ -535,6 +587,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
|||||||
'status_code': 400,
|
'status_code': 400,
|
||||||
'error_type': 'context_overflow'
|
'error_type': 'context_overflow'
|
||||||
})
|
})
|
||||||
|
finalize_user_work_timer()
|
||||||
return
|
return
|
||||||
usage_percent = (current_tokens / max_context_tokens) * 100
|
usage_percent = (current_tokens / max_context_tokens) * 100
|
||||||
warned = web_terminal.context_manager.conversation_metadata.get("context_warning_sent", False)
|
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': "配额已达到上限,暂时无法继续调用模型。",
|
'message': "配额已达到上限,暂时无法继续调用模型。",
|
||||||
'quota': quota_info
|
'quota': quota_info
|
||||||
})
|
})
|
||||||
|
finalize_user_work_timer()
|
||||||
return
|
return
|
||||||
|
|
||||||
tool_call_limit_label = MAX_TOTAL_TOOL_CALLS if MAX_TOTAL_TOOL_CALLS is not None else "∞"
|
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,
|
accumulated_response=accumulated_response,
|
||||||
)
|
)
|
||||||
if stream_result.get("stopped"):
|
if stream_result.get("stopped"):
|
||||||
|
finalize_user_work_timer()
|
||||||
return
|
return
|
||||||
|
|
||||||
full_response = stream_result["full_response"]
|
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)
|
last_tool_call_time = tool_loop_result.get("last_tool_call_time", last_tool_call_time)
|
||||||
if tool_loop_result.get("stopped"):
|
if tool_loop_result.get("stopped"):
|
||||||
|
finalize_user_work_timer()
|
||||||
return
|
return
|
||||||
|
|
||||||
# 标记不再是第一次迭代
|
# 标记不再是第一次迭代
|
||||||
@ -923,6 +979,8 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
|||||||
socketio.start_background_task(run_poll)
|
socketio.start_background_task(run_poll)
|
||||||
|
|
||||||
# 发送完成事件(如果有子智能体在运行,前端会保持等待状态)
|
# 发送完成事件(如果有子智能体在运行,前端会保持等待状态)
|
||||||
|
if not has_running_sub_agents:
|
||||||
|
finalize_user_work_timer()
|
||||||
sender('task_complete', {
|
sender('task_complete', {
|
||||||
'total_iterations': total_iterations,
|
'total_iterations': total_iterations,
|
||||||
'total_tool_calls': total_tool_calls,
|
'total_tool_calls': total_tool_calls,
|
||||||
|
|||||||
@ -165,7 +165,8 @@ export const historyMethods = {
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: message.content || '',
|
content: message.content || '',
|
||||||
images,
|
images,
|
||||||
videos
|
videos,
|
||||||
|
metadata: message.metadata || {}
|
||||||
});
|
});
|
||||||
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
|
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
|
||||||
|
|
||||||
|
|||||||
@ -653,6 +653,34 @@ export const taskPollingMethods = {
|
|||||||
this.conditionalScrollToBottom();
|
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) {
|
handleTaskComplete(data: any) {
|
||||||
const hasRunningSubAgents = !!data?.has_running_sub_agents;
|
const hasRunningSubAgents = !!data?.has_running_sub_agents;
|
||||||
if (hasRunningSubAgents) {
|
if (hasRunningSubAgents) {
|
||||||
@ -664,6 +692,9 @@ export const taskPollingMethods = {
|
|||||||
// 同步处理状态更新
|
// 同步处理状态更新
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
if (!hasRunningSubAgents) {
|
||||||
|
this.markLatestUserWorkCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
if (hasRunningSubAgents) {
|
if (hasRunningSubAgents) {
|
||||||
this.taskInProgress = true;
|
this.taskInProgress = true;
|
||||||
@ -688,6 +719,7 @@ export const taskPollingMethods = {
|
|||||||
handleTaskStopped(data: any, eventIdx: number) {
|
handleTaskStopped(data: any, eventIdx: number) {
|
||||||
debugLog('[TaskPolling] 任务已停止, idx:', eventIdx, data);
|
debugLog('[TaskPolling] 任务已停止, idx:', eventIdx, data);
|
||||||
|
|
||||||
|
this.markLatestUserWorkCompleted();
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.taskInProgress = false;
|
this.taskInProgress = false;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
@ -790,6 +822,7 @@ export const taskPollingMethods = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 清理状态
|
// 清理状态
|
||||||
|
this.markLatestUserWorkCompleted();
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.taskInProgress = false;
|
this.taskInProgress = false;
|
||||||
this.stopRequested = 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">
|
<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 class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span>
|
||||||
<span>{{ aiAssistantName }}</span>
|
<span>{{ aiAssistantName }}</span>
|
||||||
|
<span v-if="assistantWorkLabel(index)" class="assistant-work-status">{{ assistantWorkLabel(index) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="msg.awaitingFirstContent"
|
v-if="msg.awaitingFirstContent"
|
||||||
@ -379,7 +380,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||||
import StackedBlocks from './StackedBlocks.vue';
|
import StackedBlocks from './StackedBlocks.vue';
|
||||||
import MinimalBlocks from './MinimalBlocks.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')
|
(props.messages || []).filter(m => !(m && m.metadata && m.metadata.system_injected_image) && m.role !== 'system')
|
||||||
);
|
);
|
||||||
const latestMessageIndex = computed(() => filteredMessages.value.length - 1);
|
const latestMessageIndex = computed(() => filteredMessages.value.length - 1);
|
||||||
|
const nowMs = ref(Date.now());
|
||||||
|
let timerHandle: number | null = null;
|
||||||
|
|
||||||
const DEFAULT_GENERATING_TEXT = '生成中…';
|
const DEFAULT_GENERATING_TEXT = '生成中…';
|
||||||
const rootEl = ref<HTMLElement | null>(null);
|
const rootEl = ref<HTMLElement | null>(null);
|
||||||
@ -472,6 +475,56 @@ function getPreviewUrl(path: string): string {
|
|||||||
return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
|
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 isStackable = (action: any) => action && (action.type === 'thinking' || action.type === 'tool');
|
||||||
const isEmptyTextAction = (action: any) => {
|
const isEmptyTextAction = (action: any) => {
|
||||||
if (!action || action.type !== 'text') {
|
if (!action || action.type !== 'text') {
|
||||||
|
|||||||
@ -165,11 +165,18 @@ export const useChatStore = defineStore('chat', {
|
|||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
addUserMessage(content: string, images: string[] = [], videos: string[] = []) {
|
addUserMessage(content: string, images: string[] = [], videos: string[] = []) {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
this.messages.push({
|
this.messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content,
|
content,
|
||||||
images,
|
images,
|
||||||
videos
|
videos,
|
||||||
|
metadata: {
|
||||||
|
work_timer: {
|
||||||
|
status: 'working',
|
||||||
|
started_at: startedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.currentMessageIndex = -1;
|
this.currentMessageIndex = -1;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -114,7 +114,7 @@
|
|||||||
.scroll-lock-toggle {
|
.scroll-lock-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
bottom: 140px;
|
bottom: 100px;
|
||||||
z-index: 25;
|
z-index: 25;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -378,6 +378,13 @@
|
|||||||
letter-spacing: 0.02em;
|
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,
|
.user-message .message-text,
|
||||||
.assistant-message .message-text {
|
.assistant-message .message-text {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user