LLM call logging, plan persistence API, quote-to-feedback UX, requirement input improvements
- Add llm_call_log table and per-call timing/token tracking in agent loop
- New GET /workflows/{id}/plan endpoint to restore plan from snapshots on page load
- New GET /workflows/{id}/llm-calls endpoint + WS LlmCallLog broadcast
- Parse Usage from LLM API response (prompt_tokens, completion_tokens)
- Detailed mode toggle in execution log showing LLM call cards with phase/tokens/latency
- Quote-to-feedback: hover quote buttons on plan steps and log entries, multi-quote chips in comment input
- Requirement input: larger textarea, multi-line display with pre-wrap and scroll
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
46424cfbc4
commit
0a8eee0285
@ -1,9 +1,7 @@
|
|||||||
需求输入和展示,多行,复杂需求,界面不够优化,输入框不够大。
|
需求输入和展示,多行,复杂需求,界面不够优化,输入框不够大。
|
||||||
|
|
||||||
Agent runtime 重构:统一 ProjectState + 单写者模型、context 压缩、plan-centric 反馈处理(详见 doc/context.md)
|
❯ 在前端,计划和日志,每一个条目,都应有一个小小的comment按钮,按一下,直接快速引用,然后输入docus到反馈输入那里,表示要评论的是这
|
||||||
|
个地方。这样llm也知道用户具体在指啥。 同时,允许多处引用,再点一个其他的comment按钮,就引用两处,等等。按钮做的不要太眨眼间,比
|
||||||
|
如用hover显示或者就是小一点不占地方,但要ui意图清晰易用。
|
||||||
|
|
||||||
template
|
|
||||||
|
|
||||||
---
|
|
||||||
时间观察app
|
时间观察app
|
||||||
---
|
|
||||||
|
|||||||
97
src/agent.rs
97
src/agent.rs
@ -33,6 +33,7 @@ pub enum WsMessage {
|
|||||||
RequirementUpdate { workflow_id: String, requirement: String },
|
RequirementUpdate { workflow_id: String, requirement: String },
|
||||||
ReportReady { workflow_id: String },
|
ReportReady { workflow_id: String },
|
||||||
ProjectUpdate { project_id: String, name: String },
|
ProjectUpdate { project_id: String, name: String },
|
||||||
|
LlmCallLog { workflow_id: String, entry: crate::db::LlmCallLogEntry },
|
||||||
Error { message: String },
|
Error { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +46,7 @@ pub struct PlanStepInfo {
|
|||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn plan_infos_from_state(state: &AgentState) -> Vec<PlanStepInfo> {
|
pub fn plan_infos_from_state(state: &AgentState) -> Vec<PlanStepInfo> {
|
||||||
state.steps.iter().map(|s| {
|
state.steps.iter().map(|s| {
|
||||||
let status = match s.status {
|
let status = match s.status {
|
||||||
StepStatus::Pending => "pending",
|
StepStatus::Pending => "pending",
|
||||||
@ -832,6 +833,61 @@ async fn log_execution(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log an LLM call to llm_call_log and broadcast to frontend.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn log_llm_call(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
broadcast_tx: &broadcast::Sender<WsMessage>,
|
||||||
|
workflow_id: &str,
|
||||||
|
step_order: i32,
|
||||||
|
phase: &str,
|
||||||
|
messages_count: i32,
|
||||||
|
tools_count: i32,
|
||||||
|
tool_calls_json: &str,
|
||||||
|
text_response: &str,
|
||||||
|
prompt_tokens: Option<u32>,
|
||||||
|
completion_tokens: Option<u32>,
|
||||||
|
latency_ms: i64,
|
||||||
|
) {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO llm_call_log (id, workflow_id, step_order, phase, messages_count, tools_count, tool_calls, text_response, prompt_tokens, completion_tokens, latency_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))"
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(workflow_id)
|
||||||
|
.bind(step_order)
|
||||||
|
.bind(phase)
|
||||||
|
.bind(messages_count)
|
||||||
|
.bind(tools_count)
|
||||||
|
.bind(tool_calls_json)
|
||||||
|
.bind(text_response)
|
||||||
|
.bind(prompt_tokens.map(|v| v as i32))
|
||||||
|
.bind(completion_tokens.map(|v| v as i32))
|
||||||
|
.bind(latency_ms as i32)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let entry = crate::db::LlmCallLogEntry {
|
||||||
|
id: id.clone(),
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
step_order,
|
||||||
|
phase: phase.to_string(),
|
||||||
|
messages_count,
|
||||||
|
tools_count,
|
||||||
|
tool_calls: tool_calls_json.to_string(),
|
||||||
|
text_response: text_response.to_string(),
|
||||||
|
prompt_tokens: prompt_tokens.map(|v| v as i32),
|
||||||
|
completion_tokens: completion_tokens.map(|v| v as i32),
|
||||||
|
latency_ms: latency_ms as i32,
|
||||||
|
created_at: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = broadcast_tx.send(WsMessage::LlmCallLog {
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
entry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Process user feedback: call LLM to decide whether to revise the plan.
|
/// Process user feedback: call LLM to decide whether to revise the plan.
|
||||||
/// Returns the (possibly modified) AgentState ready for resumed execution.
|
/// Returns the (possibly modified) AgentState ready for resumed execution.
|
||||||
async fn process_feedback(
|
async fn process_feedback(
|
||||||
@ -952,8 +1008,16 @@ async fn run_agent_loop(
|
|||||||
AgentPhase::Completed => break,
|
AgentPhase::Completed => break,
|
||||||
};
|
};
|
||||||
let messages = state.build_messages(&system_prompt, requirement);
|
let messages = state.build_messages(&system_prompt, requirement);
|
||||||
|
let msg_count = messages.len() as i32;
|
||||||
|
let tool_count = tools.len() as i32;
|
||||||
|
let phase_label = match &state.phase {
|
||||||
|
AgentPhase::Planning => "planning".to_string(),
|
||||||
|
AgentPhase::Executing { step } => format!("executing({})", step),
|
||||||
|
AgentPhase::Completed => "completed".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!("[workflow {}] LLM call #{} phase={:?} msgs={}", workflow_id, iteration + 1, state.phase, messages.len());
|
tracing::info!("[workflow {}] LLM call #{} phase={:?} msgs={}", workflow_id, iteration + 1, state.phase, messages.len());
|
||||||
|
let call_start = std::time::Instant::now();
|
||||||
let response = match llm.chat_with_tools(messages, tools).await {
|
let response = match llm.chat_with_tools(messages, tools).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -961,6 +1025,11 @@ async fn run_agent_loop(
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let latency_ms = call_start.elapsed().as_millis() as i64;
|
||||||
|
|
||||||
|
let (prompt_tokens, completion_tokens) = response.usage.as_ref()
|
||||||
|
.map(|u| (Some(u.prompt_tokens), Some(u.completion_tokens)))
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
let choice = response.choices.into_iter().next()
|
let choice = response.choices.into_iter().next()
|
||||||
.ok_or_else(|| anyhow::anyhow!("No response from LLM"))?;
|
.ok_or_else(|| anyhow::anyhow!("No response from LLM"))?;
|
||||||
@ -968,6 +1037,9 @@ async fn run_agent_loop(
|
|||||||
// Add assistant message to chat history
|
// Add assistant message to chat history
|
||||||
state.current_step_chat_history.push(choice.message.clone());
|
state.current_step_chat_history.push(choice.message.clone());
|
||||||
|
|
||||||
|
// Collect text_response for logging
|
||||||
|
let llm_text_response = choice.message.content.clone().unwrap_or_default();
|
||||||
|
|
||||||
if let Some(tool_calls) = &choice.message.tool_calls {
|
if let Some(tool_calls) = &choice.message.tool_calls {
|
||||||
tracing::info!("[workflow {}] Tool calls: {}", workflow_id,
|
tracing::info!("[workflow {}] Tool calls: {}", workflow_id,
|
||||||
tool_calls.iter().map(|tc| tc.function.name.as_str()).collect::<Vec<_>>().join(", "));
|
tool_calls.iter().map(|tc| tc.function.name.as_str()).collect::<Vec<_>>().join(", "));
|
||||||
@ -1184,6 +1256,22 @@ async fn run_agent_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build tool_calls JSON for LLM call log
|
||||||
|
let tc_json: Vec<serde_json::Value> = tool_calls.iter().map(|tc| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": tc.function.name,
|
||||||
|
"arguments_preview": truncate_str(&tc.function.arguments, 200),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
let tc_json_str = serde_json::to_string(&tc_json).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
|
||||||
|
log_llm_call(
|
||||||
|
pool, broadcast_tx, workflow_id, state.current_step(),
|
||||||
|
&phase_label, msg_count, tool_count,
|
||||||
|
&tc_json_str, &llm_text_response,
|
||||||
|
prompt_tokens, completion_tokens, latency_ms,
|
||||||
|
).await;
|
||||||
|
|
||||||
if phase_transition {
|
if phase_transition {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1195,6 +1283,13 @@ async fn run_agent_loop(
|
|||||||
// Log text response to execution_log for frontend display
|
// Log text response to execution_log for frontend display
|
||||||
log_execution(pool, broadcast_tx, workflow_id, state.current_step(), "text_response", "", content, "done").await;
|
log_execution(pool, broadcast_tx, workflow_id, state.current_step(), "text_response", "", content, "done").await;
|
||||||
|
|
||||||
|
log_llm_call(
|
||||||
|
pool, broadcast_tx, workflow_id, state.current_step(),
|
||||||
|
&phase_label, msg_count, tool_count,
|
||||||
|
"[]", content,
|
||||||
|
prompt_tokens, completion_tokens, latency_ms,
|
||||||
|
).await;
|
||||||
|
|
||||||
// Text response does NOT end the workflow. Only advance_step progresses.
|
// Text response does NOT end the workflow. Only advance_step progresses.
|
||||||
// In Planning phase, LLM may be thinking before calling update_plan — just continue.
|
// In Planning phase, LLM may be thinking before calling update_plan — just continue.
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,9 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::agent::AgentEvent;
|
use crate::agent::{AgentEvent, PlanStepInfo};
|
||||||
use crate::db::{Workflow, ExecutionLogEntry, Comment};
|
use crate::db::{Workflow, ExecutionLogEntry, Comment, LlmCallLogEntry};
|
||||||
|
use crate::state::AgentState;
|
||||||
use super::{ApiResult, db_err};
|
use super::{ApiResult, db_err};
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
@ -33,6 +34,8 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/workflows/{id}/steps", get(list_steps))
|
.route("/workflows/{id}/steps", get(list_steps))
|
||||||
.route("/workflows/{id}/comments", get(list_comments).post(create_comment))
|
.route("/workflows/{id}/comments", get(list_comments).post(create_comment))
|
||||||
.route("/workflows/{id}/report", get(get_report))
|
.route("/workflows/{id}/report", get(get_report))
|
||||||
|
.route("/workflows/{id}/plan", get(get_plan))
|
||||||
|
.route("/workflows/{id}/llm-calls", get(list_llm_calls))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,3 +156,38 @@ async fn get_report(
|
|||||||
None => Err((StatusCode::NOT_FOUND, "Workflow not found").into_response()),
|
None => Err((StatusCode::NOT_FOUND, "Workflow not found").into_response()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_plan(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(workflow_id): Path<String>,
|
||||||
|
) -> ApiResult<Vec<PlanStepInfo>> {
|
||||||
|
let snapshot_json: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT state_json FROM agent_state_snapshots WHERE workflow_id = ? ORDER BY created_at DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
.bind(&workflow_id)
|
||||||
|
.fetch_optional(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
if let Some(json) = snapshot_json {
|
||||||
|
if let Ok(agent_state) = serde_json::from_str::<AgentState>(&json) {
|
||||||
|
return Ok(Json(crate::agent::plan_infos_from_state(&agent_state)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(vec![]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_llm_calls(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(workflow_id): Path<String>,
|
||||||
|
) -> ApiResult<Vec<LlmCallLogEntry>> {
|
||||||
|
sqlx::query_as::<_, LlmCallLogEntry>(
|
||||||
|
"SELECT * FROM llm_call_log WHERE workflow_id = ? ORDER BY created_at"
|
||||||
|
)
|
||||||
|
.bind(&workflow_id)
|
||||||
|
.fetch_all(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(db_err)
|
||||||
|
}
|
||||||
|
|||||||
42
src/db.rs
42
src/db.rs
@ -158,6 +158,32 @@ impl Database {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS llm_call_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workflow_id TEXT NOT NULL REFERENCES workflows(id),
|
||||||
|
step_order INTEGER NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
messages_count INTEGER NOT NULL,
|
||||||
|
tools_count INTEGER NOT NULL,
|
||||||
|
tool_calls TEXT NOT NULL DEFAULT '[]',
|
||||||
|
text_response TEXT NOT NULL DEFAULT '',
|
||||||
|
prompt_tokens INTEGER,
|
||||||
|
completion_tokens INTEGER,
|
||||||
|
latency_ms INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)"
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Migration: add text_response column to llm_call_log
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"ALTER TABLE llm_call_log ADD COLUMN text_response TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS timers (
|
"CREATE TABLE IF NOT EXISTS timers (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -237,3 +263,19 @@ pub struct Timer {
|
|||||||
pub last_run_at: String,
|
pub last_run_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct LlmCallLogEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub workflow_id: String,
|
||||||
|
pub step_order: i32,
|
||||||
|
pub phase: String,
|
||||||
|
pub messages_count: i32,
|
||||||
|
pub tools_count: i32,
|
||||||
|
pub tool_calls: String,
|
||||||
|
pub text_response: String,
|
||||||
|
pub prompt_tokens: Option<i32>,
|
||||||
|
pub completion_tokens: Option<i32>,
|
||||||
|
pub latency_ms: i32,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|||||||
11
src/llm.rs
11
src/llm.rs
@ -67,9 +67,20 @@ pub struct ToolCallFunction {
|
|||||||
pub arguments: String,
|
pub arguments: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Usage {
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ChatResponse {
|
pub struct ChatResponse {
|
||||||
pub choices: Vec<ChatChoice>,
|
pub choices: Vec<ChatChoice>,
|
||||||
|
pub usage: Option<Usage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@ -4,7 +4,7 @@ mod db;
|
|||||||
mod kb;
|
mod kb;
|
||||||
mod llm;
|
mod llm;
|
||||||
mod exec;
|
mod exec;
|
||||||
mod state;
|
pub mod state;
|
||||||
mod timer;
|
mod timer;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary } from './types'
|
import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary, PlanStepInfo, LlmCallLogEntry } from './types'
|
||||||
|
|
||||||
const BASE = '/api'
|
const BASE = '/api'
|
||||||
|
|
||||||
@ -58,6 +58,12 @@ export const api = {
|
|||||||
getReport: (workflowId: string) =>
|
getReport: (workflowId: string) =>
|
||||||
request<{ report: string }>(`/workflows/${workflowId}/report`),
|
request<{ report: string }>(`/workflows/${workflowId}/report`),
|
||||||
|
|
||||||
|
listPlanSteps: (workflowId: string) =>
|
||||||
|
request<PlanStepInfo[]>(`/workflows/${workflowId}/plan`),
|
||||||
|
|
||||||
|
listLlmCalls: (workflowId: string) =>
|
||||||
|
request<LlmCallLogEntry[]>(`/workflows/${workflowId}/llm-calls`),
|
||||||
|
|
||||||
listTimers: (projectId: string) =>
|
listTimers: (projectId: string) =>
|
||||||
request<Timer[]>(`/projects/${projectId}/timers`),
|
request<Timer[]>(`/projects/${projectId}/timers`),
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
quotes: string[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [text: string]
|
submit: [text: string]
|
||||||
|
removeQuote: [index: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text) return
|
if (!text && !props.quotes.length) return
|
||||||
emit('submit', text)
|
|
||||||
|
// Build final text: quotes as block references, then user text
|
||||||
|
let final = ''
|
||||||
|
for (const q of props.quotes) {
|
||||||
|
final += `> ${q}\n`
|
||||||
|
}
|
||||||
|
if (props.quotes.length && text) {
|
||||||
|
final += '\n'
|
||||||
|
}
|
||||||
|
final += text
|
||||||
|
|
||||||
|
emit('submit', final.trim())
|
||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,18 +39,33 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
submit()
|
submit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
nextTick(() => {
|
||||||
|
textareaRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focusInput })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="comment-section">
|
<div class="comment-section">
|
||||||
|
<div v-if="quotes.length" class="quotes-bar">
|
||||||
|
<div v-for="(q, i) in quotes" :key="i" class="quote-chip">
|
||||||
|
<span class="quote-text">{{ q.length > 60 ? q.slice(0, 60) + '...' : q }}</span>
|
||||||
|
<button class="quote-remove" @click="emit('removeQuote', i)" title="移除引用">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="comment-input">
|
<div class="comment-input">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="textareaRef"
|
||||||
v-model="input"
|
v-model="input"
|
||||||
placeholder="输入反馈或调整指令... (Ctrl+Enter 发送)"
|
:placeholder="quotes.length ? '添加你的评论...' : '输入反馈或调整指令... (Ctrl+Enter 发送)'"
|
||||||
rows="3"
|
rows="3"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
/>
|
/>
|
||||||
<button class="btn-send" :disabled="disabled || !input.trim()" @click="submit">发送</button>
|
<button class="btn-send" :disabled="disabled || (!input.trim() && !quotes.length)" @click="submit">发送</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -49,6 +78,53 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quotes-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(79, 195, 247, 0.12);
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.comment-input {
|
.comment-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@ -1,18 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import type { ExecutionLogEntry, Comment } from '../types'
|
import type { ExecutionLogEntry, Comment, LlmCallLogEntry } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entries: ExecutionLogEntry[]
|
entries: ExecutionLogEntry[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
llmCalls: LlmCallLogEntry[]
|
||||||
requirement: string
|
requirement: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
workflowStatus: string
|
workflowStatus: string
|
||||||
workflowId: string
|
workflowId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
quote: [text: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function quoteEntry(e: Event, entry: ExecutionLogEntry) {
|
||||||
|
e.stopPropagation()
|
||||||
|
const label = toolLabel(entry.tool_name)
|
||||||
|
const preview = entry.tool_name === 'text_response'
|
||||||
|
? entry.output.slice(0, 80)
|
||||||
|
: entry.tool_input.slice(0, 80)
|
||||||
|
emit('quote', `[${label}] ${preview}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteLlmCall(e: Event, lc: LlmCallLogEntry) {
|
||||||
|
e.stopPropagation()
|
||||||
|
const preview = lc.text_response
|
||||||
|
? lc.text_response.slice(0, 80)
|
||||||
|
: `${lc.phase} (${lc.messages_count} msgs)`
|
||||||
|
emit('quote', `[LLM] ${preview}`)
|
||||||
|
}
|
||||||
|
|
||||||
const scrollContainer = ref<HTMLElement | null>(null)
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
const userScrolledUp = ref(false)
|
const userScrolledUp = ref(false)
|
||||||
|
const detailedMode = ref(false)
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
const el = scrollContainer.value
|
const el = scrollContainer.value
|
||||||
@ -60,11 +83,31 @@ function toolLabel(name: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number | null): string {
|
||||||
|
if (n === null || n === undefined) return '-'
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
|
||||||
|
return n.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLatency(ms: number): string {
|
||||||
|
if (ms >= 1000) return (ms / 1000).toFixed(1) + 's'
|
||||||
|
return ms + 'ms'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolCalls(json: string): { name: string; arguments_preview: string }[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(json)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface LogItem {
|
interface LogItem {
|
||||||
id: string
|
id: string
|
||||||
type: 'requirement' | 'entry' | 'comment' | 'report'
|
type: 'requirement' | 'entry' | 'comment' | 'report' | 'llm-call'
|
||||||
time: string
|
time: string
|
||||||
entry?: ExecutionLogEntry
|
entry?: ExecutionLogEntry
|
||||||
|
llmCall?: LlmCallLogEntry
|
||||||
text?: string
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +126,12 @@ const logItems = computed(() => {
|
|||||||
items.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
|
items.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (detailedMode.value) {
|
||||||
|
for (const lc of props.llmCalls) {
|
||||||
|
items.push({ id: 'llm-' + lc.id, type: 'llm-call', llmCall: lc, time: lc.created_at || '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
if (!a.time && !b.time) return 0
|
if (!a.time && !b.time) return 0
|
||||||
if (!a.time) return -1
|
if (!a.time) return -1
|
||||||
@ -94,7 +143,7 @@ const logItems = computed(() => {
|
|||||||
const result: LogItem[] = []
|
const result: LogItem[] = []
|
||||||
let lastWasEntry = false
|
let lastWasEntry = false
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.type === 'entry') {
|
if (item.type === 'entry' || item.type === 'llm-call') {
|
||||||
lastWasEntry = true
|
lastWasEntry = true
|
||||||
} else if (lastWasEntry && (item.type === 'comment' || item.type === 'requirement')) {
|
} else if (lastWasEntry && (item.type === 'comment' || item.type === 'requirement')) {
|
||||||
result.push({ id: `report-${result.length}`, type: 'report', time: '' })
|
result.push({ id: `report-${result.length}`, type: 'report', time: '' })
|
||||||
@ -126,6 +175,10 @@ watch(logItems, () => {
|
|||||||
<div class="execution-section" ref="scrollContainer" @scroll="onScroll">
|
<div class="execution-section" ref="scrollContainer" @scroll="onScroll">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>日志</h2>
|
<h2>日志</h2>
|
||||||
|
<label class="detail-toggle">
|
||||||
|
<input type="checkbox" v-model="detailedMode" />
|
||||||
|
<span>详细</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="exec-list">
|
<div class="exec-list">
|
||||||
<template v-for="item in logItems" :key="item.id">
|
<template v-for="item in logItems" :key="item.id">
|
||||||
@ -141,6 +194,30 @@ watch(logItems, () => {
|
|||||||
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 →</a>
|
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- LLM Call card (detailed mode) -->
|
||||||
|
<div v-else-if="item.type === 'llm-call' && item.llmCall" class="llm-call-card" @click="toggleEntry(item.id)">
|
||||||
|
<div class="llm-call-header">
|
||||||
|
<span class="exec-time" v-if="item.time">{{ formatTime(item.time) }}</span>
|
||||||
|
<span class="llm-badge">LLM</span>
|
||||||
|
<span class="llm-phase">{{ item.llmCall.phase }}</span>
|
||||||
|
<span class="llm-meta">{{ item.llmCall.messages_count }} msgs</span>
|
||||||
|
<span class="llm-meta">{{ formatTokens(item.llmCall.prompt_tokens) }} → {{ formatTokens(item.llmCall.completion_tokens) }}</span>
|
||||||
|
<span class="llm-meta">{{ formatLatency(item.llmCall.latency_ms) }}</span>
|
||||||
|
<button class="quote-btn" @click="quoteLlmCall($event, item.llmCall!)" title="引用到反馈">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M6.32 3.2A5.6 5.6 0 0 0 1.6 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4H3.84A3.36 3.36 0 0 1 6.32 3.2ZM14.4 3.2A5.6 5.6 0 0 0 9.68 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4h-1.36A3.36 3.36 0 0 1 14.4 3.2Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.llmCall.text_response" class="llm-text-response">
|
||||||
|
{{ item.llmCall.text_response.length > 200 ? item.llmCall.text_response.slice(0, 200) + '...' : item.llmCall.text_response }}
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedEntries.has(item.id)" class="llm-call-detail">
|
||||||
|
<div v-for="(tc, i) in parseToolCalls(item.llmCall.tool_calls)" :key="i" class="llm-tc-item">
|
||||||
|
<span class="llm-tc-name">{{ tc.name }}</span>
|
||||||
|
<span class="llm-tc-args">{{ tc.arguments_preview }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Execution log entry -->
|
<!-- Execution log entry -->
|
||||||
<div v-else-if="item.entry" class="exec-item" :class="item.entry.status">
|
<div v-else-if="item.entry" class="exec-item" :class="item.entry.status">
|
||||||
<div class="exec-header" @click="toggleEntry(item.entry!.id)">
|
<div class="exec-header" @click="toggleEntry(item.entry!.id)">
|
||||||
@ -149,6 +226,9 @@ watch(logItems, () => {
|
|||||||
<span class="exec-toggle">{{ expandedEntries.has(item.entry!.id) ? '▾' : '▸' }}</span>
|
<span class="exec-toggle">{{ expandedEntries.has(item.entry!.id) ? '▾' : '▸' }}</span>
|
||||||
<span class="exec-tool">{{ toolLabel(item.entry.tool_name) }}</span>
|
<span class="exec-tool">{{ toolLabel(item.entry.tool_name) }}</span>
|
||||||
<span class="exec-desc">{{ item.entry.tool_name === 'text_response' ? item.entry.output.slice(0, 80) : item.entry.tool_input.slice(0, 80) }}</span>
|
<span class="exec-desc">{{ item.entry.tool_name === 'text_response' ? item.entry.output.slice(0, 80) : item.entry.tool_input.slice(0, 80) }}</span>
|
||||||
|
<button class="quote-btn" @click="quoteEntry($event, item.entry!)" title="引用到反馈">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M6.32 3.2A5.6 5.6 0 0 0 1.6 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4H3.84A3.36 3.36 0 0 1 6.32 3.2ZM14.4 3.2A5.6 5.6 0 0 0 9.68 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4h-1.36A3.36 3.36 0 0 1 14.4 3.2Z"/></svg>
|
||||||
|
</button>
|
||||||
<span class="exec-status" :class="item.entry.status">{{ statusLabel(item.entry.status) }}</span>
|
<span class="exec-status" :class="item.entry.status">{{ statusLabel(item.entry.status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expandedEntries.has(item.entry!.id)" class="exec-detail">
|
<div v-if="expandedEntries.has(item.entry!.id)" class="exec-detail">
|
||||||
@ -179,6 +259,9 @@ watch(logItems, () => {
|
|||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header h2 {
|
.section-header h2 {
|
||||||
@ -189,6 +272,20 @@ watch(logItems, () => {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-toggle input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.exec-list {
|
.exec-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -280,6 +377,34 @@ watch(logItems, () => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exec-header:hover .quote-btn,
|
||||||
|
.llm-call-header:hover .quote-btn {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-btn:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: rgba(79, 195, 247, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.exec-status {
|
.exec-status {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@ -345,4 +470,80 @@ watch(logItems, () => {
|
|||||||
.report-link:hover {
|
.report-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* LLM Call Card */
|
||||||
|
.llm-call-card {
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-call-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-phase {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-meta {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-text-response {
|
||||||
|
padding: 4px 10px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
opacity: 0.85;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-call-detail {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-tc-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-tc-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-tc-args {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -6,6 +6,10 @@ defineProps<{
|
|||||||
steps: PlanStepInfo[]
|
steps: PlanStepInfo[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
quote: [text: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
const expandedSteps = ref<Set<number>>(new Set())
|
const expandedSteps = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
function toggleStep(order: number) {
|
function toggleStep(order: number) {
|
||||||
@ -24,6 +28,11 @@ function statusIcon(status?: string) {
|
|||||||
default: return '○'
|
default: return '○'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quoteStep(e: Event, step: PlanStepInfo) {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('quote', `[步骤${step.order}] ${step.description}`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -42,6 +51,9 @@ function statusIcon(status?: string) {
|
|||||||
<span class="step-icon">{{ statusIcon(step.status) }}</span>
|
<span class="step-icon">{{ statusIcon(step.status) }}</span>
|
||||||
<span class="step-order">{{ step.order }}.</span>
|
<span class="step-order">{{ step.order }}.</span>
|
||||||
<span class="step-title">{{ step.description }}</span>
|
<span class="step-title">{{ step.description }}</span>
|
||||||
|
<button class="quote-btn" @click="quoteStep($event, step)" title="引用到反馈">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M6.32 3.2A5.6 5.6 0 0 0 1.6 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4H3.84A3.36 3.36 0 0 1 6.32 3.2ZM14.4 3.2A5.6 5.6 0 0 0 9.68 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4h-1.36A3.36 3.36 0 0 1 14.4 3.2Z"/></svg>
|
||||||
|
</button>
|
||||||
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.order) ? '▾' : '▸' }}</span>
|
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.order) ? '▾' : '▸' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="step.command && expandedSteps.has(step.order)" class="step-detail">
|
<div v-if="step.command && expandedSteps.has(step.order)" class="step-detail">
|
||||||
@ -135,6 +147,33 @@ function statusIcon(status?: string) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header:hover .quote-btn {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-btn:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: rgba(79, 195, 247, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.step-toggle {
|
.step-toggle {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@ -37,14 +37,14 @@ function submit() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!editing && requirement" class="requirement-display">
|
<div v-if="!editing && requirement" class="requirement-display">
|
||||||
<span>{{ requirement }}</span>
|
<pre class="requirement-text">{{ requirement }}</pre>
|
||||||
<button class="btn-edit" @click="editing = true; input = requirement">编辑</button>
|
<button class="btn-edit" @click="editing = true; input = requirement">编辑</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="requirement-input">
|
<div v-else class="requirement-input">
|
||||||
<textarea
|
<textarea
|
||||||
v-model="input"
|
v-model="input"
|
||||||
placeholder="描述你的需求..."
|
placeholder="描述你的需求... (支持多行,Ctrl+Enter 提交)"
|
||||||
rows="3"
|
rows="8"
|
||||||
@keydown.ctrl.enter="submit"
|
@keydown.ctrl.enter="submit"
|
||||||
/>
|
/>
|
||||||
<button class="btn-submit" @click="submit">提交需求</button>
|
<button class="btn-submit" @click="submit">提交需求</button>
|
||||||
@ -89,7 +89,7 @@ function submit() {
|
|||||||
|
|
||||||
.requirement-display {
|
.requirement-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@ -97,6 +97,18 @@ function submit() {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.requirement-text {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-edit {
|
.btn-edit {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
@ -125,6 +137,10 @@ function submit() {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 300px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.requirement-input textarea:focus {
|
.requirement-input textarea:focus {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import CommentSection from './CommentSection.vue'
|
|||||||
import TimerSection from './TimerSection.vue'
|
import TimerSection from './TimerSection.vue'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { connectWs } from '../ws'
|
import { connectWs } from '../ws'
|
||||||
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment } from '../types'
|
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment, LlmCallLogEntry } from '../types'
|
||||||
import type { WsMessage } from '../ws'
|
import type { WsMessage } from '../ws'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -22,8 +22,23 @@ const workflow = ref<Workflow | null>(null)
|
|||||||
const logEntries = ref<ExecutionLogEntry[]>([])
|
const logEntries = ref<ExecutionLogEntry[]>([])
|
||||||
const planSteps = ref<PlanStepInfo[]>([])
|
const planSteps = ref<PlanStepInfo[]>([])
|
||||||
const comments = ref<Comment[]>([])
|
const comments = ref<Comment[]>([])
|
||||||
|
const llmCalls = ref<LlmCallLogEntry[]>([])
|
||||||
|
const quotes = ref<string[]>([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const rightTab = ref<'log' | 'timers'>('log')
|
const rightTab = ref<'log' | 'timers'>('log')
|
||||||
|
const commentRef = ref<InstanceType<typeof CommentSection> | null>(null)
|
||||||
|
|
||||||
|
function addQuote(text: string) {
|
||||||
|
// Avoid duplicate
|
||||||
|
if (!quotes.value.includes(text)) {
|
||||||
|
quotes.value.push(text)
|
||||||
|
}
|
||||||
|
commentRef.value?.focusInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQuote(index: number) {
|
||||||
|
quotes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
let wsConn: { close: () => void } | null = null
|
let wsConn: { close: () => void } | null = null
|
||||||
|
|
||||||
@ -33,17 +48,22 @@ async function loadData() {
|
|||||||
const latest = workflows[0]
|
const latest = workflows[0]
|
||||||
if (latest) {
|
if (latest) {
|
||||||
workflow.value = latest
|
workflow.value = latest
|
||||||
const [entries, c] = await Promise.all([
|
const [entries, c, plan, lc] = await Promise.all([
|
||||||
api.listSteps(latest.id),
|
api.listSteps(latest.id),
|
||||||
api.listComments(latest.id),
|
api.listComments(latest.id),
|
||||||
|
api.listPlanSteps(latest.id),
|
||||||
|
api.listLlmCalls(latest.id),
|
||||||
])
|
])
|
||||||
logEntries.value = entries
|
logEntries.value = entries
|
||||||
comments.value = c
|
comments.value = c
|
||||||
|
planSteps.value = plan
|
||||||
|
llmCalls.value = lc
|
||||||
} else {
|
} else {
|
||||||
workflow.value = null
|
workflow.value = null
|
||||||
logEntries.value = []
|
logEntries.value = []
|
||||||
planSteps.value = []
|
planSteps.value = []
|
||||||
comments.value = []
|
comments.value = []
|
||||||
|
llmCalls.value = []
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
@ -87,6 +107,11 @@ function handleWsMessage(msg: WsMessage) {
|
|||||||
case 'ProjectUpdate':
|
case 'ProjectUpdate':
|
||||||
emit('projectUpdate', msg.project_id, msg.name)
|
emit('projectUpdate', msg.project_id, msg.name)
|
||||||
break
|
break
|
||||||
|
case 'LlmCallLog':
|
||||||
|
if (workflow.value && msg.workflow_id === workflow.value.id) {
|
||||||
|
llmCalls.value = [...llmCalls.value, msg.entry]
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'Error':
|
case 'Error':
|
||||||
error.value = msg.message
|
error.value = msg.message
|
||||||
break
|
break
|
||||||
@ -119,6 +144,7 @@ async function onSubmitRequirement(text: string) {
|
|||||||
logEntries.value = []
|
logEntries.value = []
|
||||||
planSteps.value = []
|
planSteps.value = []
|
||||||
comments.value = []
|
comments.value = []
|
||||||
|
llmCalls.value = []
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
}
|
}
|
||||||
@ -129,6 +155,7 @@ async function onSubmitComment(text: string) {
|
|||||||
try {
|
try {
|
||||||
const comment = await api.createComment(workflow.value.id, text)
|
const comment = await api.createComment(workflow.value.id, text)
|
||||||
comments.value.push(comment)
|
comments.value.push(comment)
|
||||||
|
quotes.value = []
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
}
|
}
|
||||||
@ -144,7 +171,7 @@ async function onSubmitComment(text: string) {
|
|||||||
@submit="onSubmitRequirement"
|
@submit="onSubmitRequirement"
|
||||||
/>
|
/>
|
||||||
<div class="plan-exec-row">
|
<div class="plan-exec-row">
|
||||||
<PlanSection :steps="planSteps" />
|
<PlanSection :steps="planSteps" @quote="addQuote" />
|
||||||
<div class="right-panel">
|
<div class="right-panel">
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
|
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
|
||||||
@ -154,10 +181,12 @@ async function onSubmitComment(text: string) {
|
|||||||
v-show="rightTab === 'log'"
|
v-show="rightTab === 'log'"
|
||||||
:entries="logEntries"
|
:entries="logEntries"
|
||||||
:comments="comments"
|
:comments="comments"
|
||||||
|
:llmCalls="llmCalls"
|
||||||
:requirement="workflow?.requirement || ''"
|
:requirement="workflow?.requirement || ''"
|
||||||
:createdAt="workflow?.created_at || ''"
|
:createdAt="workflow?.created_at || ''"
|
||||||
:workflowStatus="workflow?.status || 'pending'"
|
:workflowStatus="workflow?.status || 'pending'"
|
||||||
:workflowId="workflow?.id || ''"
|
:workflowId="workflow?.id || ''"
|
||||||
|
@quote="addQuote"
|
||||||
/>
|
/>
|
||||||
<TimerSection
|
<TimerSection
|
||||||
v-show="rightTab === 'timers'"
|
v-show="rightTab === 'timers'"
|
||||||
@ -166,8 +195,11 @@ async function onSubmitComment(text: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CommentSection
|
<CommentSection
|
||||||
|
ref="commentRef"
|
||||||
:disabled="!workflow"
|
:disabled="!workflow"
|
||||||
|
:quotes="quotes"
|
||||||
@submit="onSubmitComment"
|
@submit="onSubmitComment"
|
||||||
|
@removeQuote="removeQuote"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -63,3 +63,18 @@ export interface Timer {
|
|||||||
last_run_at: string
|
last_run_at: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LlmCallLogEntry {
|
||||||
|
id: string
|
||||||
|
workflow_id: string
|
||||||
|
step_order: number
|
||||||
|
phase: string
|
||||||
|
messages_count: number
|
||||||
|
tools_count: number
|
||||||
|
tool_calls: string
|
||||||
|
text_response: string
|
||||||
|
prompt_tokens: number | null
|
||||||
|
completion_tokens: number | null
|
||||||
|
latency_ms: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|||||||
@ -34,12 +34,18 @@ export interface WsProjectUpdate {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WsLlmCallLog {
|
||||||
|
type: 'LlmCallLog'
|
||||||
|
workflow_id: string
|
||||||
|
entry: import('./types').LlmCallLogEntry
|
||||||
|
}
|
||||||
|
|
||||||
export interface WsError {
|
export interface WsError {
|
||||||
type: 'Error'
|
type: 'Error'
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsRequirementUpdate | WsReportReady | WsProjectUpdate | WsError
|
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsRequirementUpdate | WsReportReady | WsProjectUpdate | WsLlmCallLog | WsError
|
||||||
|
|
||||||
export type WsHandler = (msg: WsMessage) => void
|
export type WsHandler = (msg: WsMessage) => void
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user