Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54ce1119b8 | |||
| b5df7caad8 | |||
| 99f9d5995d |
@@ -52,6 +52,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@visactor/react-vchart": "^2.0.21",
|
"@visactor/react-vchart": "^2.0.21",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
+189
-2
@@ -40,9 +40,9 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use futures::stream::Stream;
|
use futures::stream::Stream;
|
||||||
use open_agent_sdk::api::openai::OpenAIProvider;
|
use open_agent_sdk::api::openai::OpenAIProvider;
|
||||||
use open_agent_sdk::types::{ContentBlock, Message, PermissionDecision};
|
use open_agent_sdk::types::{ContentBlock, Message, MessageRole, PermissionDecision};
|
||||||
use open_agent_sdk::{Agent, AgentOptions, CancellationToken, LLMProvider, SDKMessage};
|
use open_agent_sdk::{Agent, AgentOptions, CancellationToken, LLMProvider, SDKMessage};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -74,6 +74,8 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/api/agent/approve", post(approve_handler))
|
.route("/api/agent/approve", post(approve_handler))
|
||||||
.route("/api/agent/answer", post(answer_handler))
|
.route("/api/agent/answer", post(answer_handler))
|
||||||
.route("/api/agent/status", get(status_handler))
|
.route("/api/agent/status", get(status_handler))
|
||||||
|
.route("/api/agent/sessions", get(list_sessions_handler))
|
||||||
|
.route("/api/agent/sessions/{id}", get(get_session_handler))
|
||||||
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +366,12 @@ async fn chat_handler(
|
|||||||
tracing::warn!(%e, "failed to ensure agent session row");
|
tracing::warn!(%e, "failed to ensure agent session row");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Title the session by its opening prompt (no-op on later turns) so the
|
||||||
|
// history list shows something meaningful instead of a bare uuid.
|
||||||
|
if let Err(e) = session_store::set_title_if_empty(&state.db, &session_id, &body.message).await {
|
||||||
|
tracing::warn!(%e, "failed to set agent session title");
|
||||||
|
}
|
||||||
|
|
||||||
// Load any prior conversation for this session so the agent sees the full
|
// Load any prior conversation for this session so the agent sees the full
|
||||||
// multi-turn history (scoped to the caller — never another tenant's).
|
// multi-turn history (scoped to the caller — never another tenant's).
|
||||||
let history: Vec<Message> = match session_store::load_transcript(
|
let history: Vec<Message> = match session_store::load_transcript(
|
||||||
@@ -654,3 +662,182 @@ struct StatusQuery {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ListSessionsQuery {
|
||||||
|
/// Max sessions to return (most-recent first); clamped to a sane ceiling.
|
||||||
|
#[serde(default)]
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/agent/sessions — the caller's own session history (summaries only).
|
||||||
|
async fn list_sessions_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<ListSessionsQuery>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
let limit = q.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
let sessions = session_store::list_sessions(&state.db, auth.user_id, limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Json(json!({ "ok": true, "sessions": sessions })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/agent/sessions/{id} — a session's accounting plus its conversation
|
||||||
|
/// transcript rebuilt into the frontend chat shape. Scoped to the caller, so a
|
||||||
|
/// user can never read another tenant's history.
|
||||||
|
async fn get_session_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
axum::extract::Path(id): axum::extract::Path<String>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
|
||||||
|
let session = session_store::get_session(&state.db, &id, auth.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "error": "session not found" })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let messages = match session_store::load_transcript(&state.db, &id, auth.user_id).await {
|
||||||
|
Ok(Some(raw)) => match serde_json::from_str::<Vec<Message>>(&raw) {
|
||||||
|
Ok(msgs) => transcript_to_chat(&msgs),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%e, "failed to parse transcript for restore");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(None) => Vec::new(),
|
||||||
|
Err(e) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"session": session,
|
||||||
|
"messages": messages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A restored tool call, shaped to match the frontend `ToolCall` type.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChatToolView {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
result: Option<String>,
|
||||||
|
#[serde(rename = "isError", skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
is_error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A restored chat message, shaped to match the frontend `ChatMessage` type.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChatMessageView {
|
||||||
|
role: &'static str,
|
||||||
|
text: String,
|
||||||
|
tools: Vec<ChatToolView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the SDK transcript (`Vec<Message>`) into the frontend's flat chat
|
||||||
|
/// shape: user/assistant bubbles, each assistant carrying its tool calls with
|
||||||
|
/// the results stitched back in. Tool results live in the *following* user
|
||||||
|
/// message (Anthropic format), so we index tool calls by id and attach results
|
||||||
|
/// as we encounter them. Pure tool-result carrier messages (no text) don't
|
||||||
|
/// become their own bubble; thinking blocks are omitted from the restore view.
|
||||||
|
fn transcript_to_chat(messages: &[Message]) -> Vec<ChatMessageView> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut chat: Vec<ChatMessageView> = Vec::new();
|
||||||
|
// tool_use_id -> (chat message index, tool index within that message)
|
||||||
|
let mut tool_loc: HashMap<String, (usize, usize)> = HashMap::new();
|
||||||
|
|
||||||
|
let push_text = |buf: &mut String, t: &str| {
|
||||||
|
if !t.is_empty() {
|
||||||
|
if !buf.is_empty() {
|
||||||
|
buf.push('\n');
|
||||||
|
}
|
||||||
|
buf.push_str(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for msg in messages {
|
||||||
|
match msg.role {
|
||||||
|
MessageRole::Assistant => {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut tools: Vec<ChatToolView> = Vec::new();
|
||||||
|
for block in &msg.content {
|
||||||
|
match block {
|
||||||
|
ContentBlock::Text { text: t } => push_text(&mut text, t),
|
||||||
|
ContentBlock::ToolUse { id, name, input } => tools.push(ChatToolView {
|
||||||
|
id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
input: input.clone(),
|
||||||
|
result: None,
|
||||||
|
is_error: false,
|
||||||
|
}),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let msg_idx = chat.len();
|
||||||
|
for (ti, tool) in tools.iter().enumerate() {
|
||||||
|
tool_loc.insert(tool.id.clone(), (msg_idx, ti));
|
||||||
|
}
|
||||||
|
chat.push(ChatMessageView { role: "assistant", text, tools });
|
||||||
|
}
|
||||||
|
MessageRole::User => {
|
||||||
|
let mut text = String::new();
|
||||||
|
for block in &msg.content {
|
||||||
|
match block {
|
||||||
|
ContentBlock::Text { text: t } => push_text(&mut text, t),
|
||||||
|
ContentBlock::ToolResult { tool_use_id, content, is_error } => {
|
||||||
|
let result_text = content
|
||||||
|
.iter()
|
||||||
|
.map(|c| match c {
|
||||||
|
open_agent_sdk::types::ToolResultContentBlock::Text { text } => {
|
||||||
|
text.clone()
|
||||||
|
}
|
||||||
|
open_agent_sdk::types::ToolResultContentBlock::Image { .. } => {
|
||||||
|
"[image]".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
if let Some((mi, ti)) = tool_loc.get(tool_use_id) {
|
||||||
|
if let Some(tool) = chat.get_mut(*mi).and_then(|m| m.tools.get_mut(*ti))
|
||||||
|
{
|
||||||
|
tool.result = Some(result_text);
|
||||||
|
tool.is_error = *is_error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A user message that only carried tool results is not its own bubble.
|
||||||
|
if !text.is_empty() {
|
||||||
|
chat.push(ChatMessageView { role: "user", text, tools: Vec::new() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chat
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,6 +185,82 @@ pub async fn get_session(
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session summary row for the history list (no transcript blob — that is
|
||||||
|
/// fetched separately when a session is opened).
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AgentSessionSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub model: String,
|
||||||
|
pub permission_mode: String,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub num_turns: i64,
|
||||||
|
pub message_count: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List a caller's sessions, most-recently-updated first, capped at `limit`.
|
||||||
|
/// Scoped by `user_id` exactly like the other accessors: a user sees only their
|
||||||
|
/// own rows, an admin (`None`) sees the NULL-`user_id` rows.
|
||||||
|
pub async fn list_sessions(
|
||||||
|
db: &SqlitePool,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
limit: i64,
|
||||||
|
) -> AppResult<Vec<AgentSessionSummary>> {
|
||||||
|
let rows = match user_id {
|
||||||
|
Some(uid) => {
|
||||||
|
sqlx::query_as::<_, AgentSessionSummary>(
|
||||||
|
"SELECT id, title, model, permission_mode, total_tokens, cost_usd, \
|
||||||
|
num_turns, message_count, created_at, updated_at \
|
||||||
|
FROM agent_sessions WHERE user_id = ? \
|
||||||
|
ORDER BY updated_at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(uid)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_as::<_, AgentSessionSummary>(
|
||||||
|
"SELECT id, title, model, permission_mode, total_tokens, cost_usd, \
|
||||||
|
num_turns, message_count, created_at, updated_at \
|
||||||
|
FROM agent_sessions WHERE user_id IS NULL \
|
||||||
|
ORDER BY updated_at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a session's title from its first prompt, but only if it has none yet.
|
||||||
|
/// Called once per turn; the `title IS NULL` guard makes later turns no-ops, so
|
||||||
|
/// the title stays pinned to whatever opened the conversation.
|
||||||
|
pub async fn set_title_if_empty(
|
||||||
|
db: &SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let trimmed = title.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Keep titles short for the list rail.
|
||||||
|
let title: String = trimmed.chars().take(60).collect();
|
||||||
|
sqlx::query("UPDATE agent_sessions SET title = ? WHERE id = ? AND title IS NULL")
|
||||||
|
.bind(title)
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -318,4 +394,36 @@ mod tests {
|
|||||||
assert!(session_is_claimable(&db, "c2", None).await.unwrap());
|
assert!(session_is_claimable(&db, "c2", None).await.unwrap());
|
||||||
assert!(!session_is_claimable(&db, "c2", Some(9)).await.unwrap());
|
assert!(!session_is_claimable(&db, "c2", Some(9)).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_sessions_is_user_scoped_and_ordered() {
|
||||||
|
let db = pool().await;
|
||||||
|
ensure_session(&db, "a", Some(1), "m", "default").await.unwrap();
|
||||||
|
ensure_session(&db, "b", Some(1), "m", "default").await.unwrap();
|
||||||
|
ensure_session(&db, "c", Some(2), "m", "default").await.unwrap();
|
||||||
|
ensure_session(&db, "admin", None, "m", "default").await.unwrap();
|
||||||
|
|
||||||
|
// Bump "a" so it sorts ahead of "b" (most-recent first).
|
||||||
|
record_turn(&db, "a", UsageDelta::default(), 2).await.unwrap();
|
||||||
|
|
||||||
|
let u1 = list_sessions(&db, Some(1), 50).await.unwrap();
|
||||||
|
let ids: Vec<&str> = u1.iter().map(|s| s.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["a", "b"]); // user 1 only, a before b
|
||||||
|
assert!(u1.iter().all(|s| s.id != "c" && s.id != "admin"));
|
||||||
|
|
||||||
|
// Admin sees only the NULL-user row.
|
||||||
|
let admin = list_sessions(&db, None, 50).await.unwrap();
|
||||||
|
assert_eq!(admin.len(), 1);
|
||||||
|
assert_eq!(admin[0].id, "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_title_only_fills_when_empty() {
|
||||||
|
let db = pool().await;
|
||||||
|
ensure_session(&db, "ti", Some(1), "m", "default").await.unwrap();
|
||||||
|
set_title_if_empty(&db, "ti", "first prompt").await.unwrap();
|
||||||
|
set_title_if_empty(&db, "ti", "later prompt").await.unwrap();
|
||||||
|
let s = list_sessions(&db, Some(1), 1).await.unwrap();
|
||||||
|
assert_eq!(s[0].title.as_deref(), Some("first prompt"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23052,3 +23052,89 @@ textarea:focus {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent Workbench — markdown-rendered assistant messages. */
|
||||||
|
.agent-markdown {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown h1,
|
||||||
|
.agent-markdown h2,
|
||||||
|
.agent-markdown h3,
|
||||||
|
.agent-markdown h4 {
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown ul,
|
||||||
|
.agent-markdown ol {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown li {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown a {
|
||||||
|
color: var(--color-primary, #4a9eff);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown blockquote {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 2px 12px;
|
||||||
|
border-left: 3px solid var(--color-border, #2a2a2a);
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code. */
|
||||||
|
.agent-markdown code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.88em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--color-surface-subtle, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fenced code blocks (highlight.js github-dark theme paints the tokens). */
|
||||||
|
.agent-markdown pre {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--color-border, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown pre code {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-markdown th,
|
||||||
|
.agent-markdown td {
|
||||||
|
border: 1px solid var(--color-border, #2a2a2a);
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Shared markdown renderer for chat-style surfaces (ComfyUI Agent, Agent
|
||||||
|
// Workbench). Renders GitHub-flavored markdown via `marked`, highlights fenced
|
||||||
|
// code blocks with highlight.js, then runs the output through a strict
|
||||||
|
// allowlist sanitizer before it is handed to `dangerouslySetInnerHTML`.
|
||||||
|
|
||||||
|
import { Marked } from 'marked';
|
||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
|
||||||
|
// A focused set of languages — enough to cover what an engineering agent
|
||||||
|
// emits, without pulling in highlight.js's full ~200-language bundle.
|
||||||
|
import bash from 'highlight.js/lib/languages/bash';
|
||||||
|
import c from 'highlight.js/lib/languages/c';
|
||||||
|
import cpp from 'highlight.js/lib/languages/cpp';
|
||||||
|
import css from 'highlight.js/lib/languages/css';
|
||||||
|
import diff from 'highlight.js/lib/languages/diff';
|
||||||
|
import dockerfile from 'highlight.js/lib/languages/dockerfile';
|
||||||
|
import go from 'highlight.js/lib/languages/go';
|
||||||
|
import ini from 'highlight.js/lib/languages/ini';
|
||||||
|
import java from 'highlight.js/lib/languages/java';
|
||||||
|
import javascript from 'highlight.js/lib/languages/javascript';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
import markdown from 'highlight.js/lib/languages/markdown';
|
||||||
|
import python from 'highlight.js/lib/languages/python';
|
||||||
|
import rust from 'highlight.js/lib/languages/rust';
|
||||||
|
import sql from 'highlight.js/lib/languages/sql';
|
||||||
|
import typescript from 'highlight.js/lib/languages/typescript';
|
||||||
|
import xml from 'highlight.js/lib/languages/xml';
|
||||||
|
import yaml from 'highlight.js/lib/languages/yaml';
|
||||||
|
|
||||||
|
import 'highlight.js/styles/github-dark.css';
|
||||||
|
|
||||||
|
hljs.registerLanguage('bash', bash);
|
||||||
|
hljs.registerLanguage('c', c);
|
||||||
|
hljs.registerLanguage('cpp', cpp);
|
||||||
|
hljs.registerLanguage('css', css);
|
||||||
|
hljs.registerLanguage('diff', diff);
|
||||||
|
hljs.registerLanguage('dockerfile', dockerfile);
|
||||||
|
hljs.registerLanguage('go', go);
|
||||||
|
hljs.registerLanguage('ini', ini);
|
||||||
|
hljs.registerLanguage('java', java);
|
||||||
|
hljs.registerLanguage('javascript', javascript);
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
hljs.registerLanguage('markdown', markdown);
|
||||||
|
hljs.registerLanguage('python', python);
|
||||||
|
hljs.registerLanguage('rust', rust);
|
||||||
|
hljs.registerLanguage('sql', sql);
|
||||||
|
hljs.registerLanguage('typescript', typescript);
|
||||||
|
hljs.registerLanguage('xml', xml);
|
||||||
|
hljs.registerLanguage('yaml', yaml);
|
||||||
|
|
||||||
|
// Common aliases so a fenced ```sh / ```yml / ```html block still highlights.
|
||||||
|
hljs.registerAliases(['sh', 'shell', 'zsh'], { languageName: 'bash' });
|
||||||
|
hljs.registerAliases(['ts', 'tsx'], { languageName: 'typescript' });
|
||||||
|
hljs.registerAliases(['js', 'jsx', 'node'], { languageName: 'javascript' });
|
||||||
|
hljs.registerAliases(['py'], { languageName: 'python' });
|
||||||
|
hljs.registerAliases(['rs'], { languageName: 'rust' });
|
||||||
|
hljs.registerAliases(['yml'], { languageName: 'yaml' });
|
||||||
|
hljs.registerAliases(['html', 'xhtml', 'svg'], { languageName: 'xml' });
|
||||||
|
hljs.registerAliases(['toml'], { languageName: 'ini' });
|
||||||
|
hljs.registerAliases(['dockerfile'], { languageName: 'dockerfile' });
|
||||||
|
|
||||||
|
export function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
|
||||||
|
const value = String(raw || '').trim();
|
||||||
|
if (!value) return null;
|
||||||
|
if (value.startsWith('#') || value.startsWith('/')) return value;
|
||||||
|
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
|
||||||
|
if (/^https?:\/\//i.test(value)) return value;
|
||||||
|
if (/^mailto:/i.test(value) || /^tel:/i.test(value)) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight a fenced code block, returning ready-to-insert inner HTML with
|
||||||
|
// `hljs-*` token spans. Falls back to plain escaped text on an unknown/missing
|
||||||
|
// language so output is never raw, unescaped code.
|
||||||
|
function highlightCode(code: string, lang: string): string {
|
||||||
|
const language = lang.trim().toLowerCase();
|
||||||
|
if (language && hljs.getLanguage(language)) {
|
||||||
|
try {
|
||||||
|
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
|
||||||
|
} catch {
|
||||||
|
/* fall through to escaped plain text */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escapeHtml(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip everything not on the allowlist. Tags outside the set are unwrapped
|
||||||
|
// (children kept), a few dangerous ones are dropped entirely, and attributes
|
||||||
|
// are whitelisted per-tag. `span` with `hljs-*` classes is permitted so the
|
||||||
|
// syntax-highlight token markup survives sanitization.
|
||||||
|
function sanitizeMarkdownHtml(html: string): string {
|
||||||
|
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
|
||||||
|
return `<p>${escapeHtml(html)}</p>`;
|
||||||
|
}
|
||||||
|
const allowedTags = new Set([
|
||||||
|
'a', 'blockquote', 'br', 'code', 'del', 'em', 'h1', 'h2', 'h3', 'h4',
|
||||||
|
'h5', 'h6', 'hr', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong',
|
||||||
|
'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul',
|
||||||
|
]);
|
||||||
|
const droppedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
|
||||||
|
const voidTags = new Set(['br', 'hr', 'img']);
|
||||||
|
const isHljsClass = (cls: string) => /^(hljs(\s|$)|language-[a-z0-9_-]+$)/i.test(cls);
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
|
||||||
|
const cleanNode = (node: Node): string => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return escapeHtml(node.textContent || '');
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||||
|
const element = node as Element;
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
if (droppedTags.has(tag)) return '';
|
||||||
|
const children = Array.from(element.childNodes).map(cleanNode).join('');
|
||||||
|
if (!allowedTags.has(tag)) return children;
|
||||||
|
const attrs: string[] = [];
|
||||||
|
if (tag === 'a') {
|
||||||
|
const href = sanitizeUrl(element.getAttribute('href'));
|
||||||
|
if (href) {
|
||||||
|
attrs.push(` href="${escapeHtml(href)}"`);
|
||||||
|
attrs.push(' target="_blank" rel="noopener noreferrer"');
|
||||||
|
}
|
||||||
|
} else if (tag === 'img') {
|
||||||
|
const src = sanitizeUrl(element.getAttribute('src'), true);
|
||||||
|
if (!src) return '';
|
||||||
|
attrs.push(` src="${escapeHtml(src)}"`);
|
||||||
|
const alt = element.getAttribute('alt');
|
||||||
|
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
|
||||||
|
} else if (tag === 'code' || tag === 'span') {
|
||||||
|
// Keep highlight.js / language-* classes only; drop anything else.
|
||||||
|
const className = String(element.getAttribute('class') || '')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((c) => isHljsClass(c))
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
if (className) attrs.push(` class="${escapeHtml(className)}"`);
|
||||||
|
else if (tag === 'span') return children; // a bare span carries no meaning
|
||||||
|
}
|
||||||
|
return voidTags.has(tag)
|
||||||
|
? `<${tag}${attrs.join('')}>`
|
||||||
|
: `<${tag}${attrs.join('')}>${children}</${tag}>`;
|
||||||
|
};
|
||||||
|
return Array.from(doc.body.childNodes).map(cleanNode).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single shared Marked instance configured for GFM + highlighted code blocks.
|
||||||
|
const md = new Marked({ gfm: true, breaks: true });
|
||||||
|
md.use({
|
||||||
|
renderer: {
|
||||||
|
code({ text, lang }: { text: string; lang?: string }): string {
|
||||||
|
const language = (lang || '').trim().toLowerCase();
|
||||||
|
const highlighted = highlightCode(text, language);
|
||||||
|
const langClass = language ? ` language-${language}` : '';
|
||||||
|
return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Render markdown to sanitized, syntax-highlighted HTML. Safe for innerHTML. */
|
||||||
|
export function renderMarkdown(content: string): string {
|
||||||
|
const raw = String(content || '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
const rendered = String(md.parse(raw));
|
||||||
|
return sanitizeMarkdownHtml(rendered || `<p>${escapeHtml(raw)}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { getSessionToken } from '../authSession.js';
|
import { getSessionToken } from '../authSession.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
|
import { renderMarkdown } from '../lib/markdown.js';
|
||||||
|
|
||||||
// Agent Workbench — a minimal chat UI over POST /api/agent/chat (SSE).
|
// Agent Workbench — a minimal chat UI over POST /api/agent/chat (SSE).
|
||||||
// Streams assistant text, renders tool-call cards, surfaces interactive
|
// Streams assistant text, renders tool-call cards, surfaces interactive
|
||||||
@@ -45,6 +46,18 @@ type SessionUsage = {
|
|||||||
numTurns: number;
|
numTurns: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A past session summary for the history rail (matches the backend `/sessions`). */
|
||||||
|
type SessionSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
model: string;
|
||||||
|
total_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
num_turns: number;
|
||||||
|
message_count: number;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
type SseEvent = { type: string; data?: Record<string, unknown> };
|
type SseEvent = { type: string; data?: Record<string, unknown> };
|
||||||
|
|
||||||
const MODE_LABELS: Record<PermissionMode, string> = {
|
const MODE_LABELS: Record<PermissionMode, string> = {
|
||||||
@@ -82,6 +95,19 @@ async function readSse(response: Response, onEvent: (e: SseEvent) => void): Prom
|
|||||||
function formatCost(usd: number): string {
|
function formatCost(usd: number): string {
|
||||||
return `$${usd.toFixed(4)}`;
|
return `$${usd.toFixed(4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sessions store `updated_at` as a SQLite UTC string ("YYYY-MM-DD HH:MM:SS");
|
||||||
|
// render it as a short, human relative label for the history rail.
|
||||||
|
function formatRelative(sqliteUtc: string): string {
|
||||||
|
const ts = Date.parse(sqliteUtc.replace(' ', 'T') + 'Z');
|
||||||
|
if (Number.isNaN(ts)) return sqliteUtc;
|
||||||
|
const diffSec = Math.round((Date.now() - ts) / 1000);
|
||||||
|
if (diffSec < 60) return '刚刚';
|
||||||
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
|
||||||
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)} 小时前`;
|
||||||
|
if (diffSec < 604800) return `${Math.floor(diffSec / 86400)} 天前`;
|
||||||
|
return new Date(ts).toLocaleDateString();
|
||||||
|
}
|
||||||
export default function AgentWorkbench() {
|
export default function AgentWorkbench() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -96,6 +122,11 @@ export default function AgentWorkbench() {
|
|||||||
const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
|
const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
|
||||||
const [ask, setAsk] = useState<AskPrompt | null>(null);
|
const [ask, setAsk] = useState<AskPrompt | null>(null);
|
||||||
|
|
||||||
|
// History rail: the caller's past sessions, restorable on click.
|
||||||
|
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||||
|
const [showHistory, setShowHistory] = useState(true);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
|
||||||
// Lets the user stop a running turn: aborting the fetch drops the SSE stream,
|
// Lets the user stop a running turn: aborting the fetch drops the SSE stream,
|
||||||
// which the backend observes and uses to cancel the agent loop.
|
// which the backend observes and uses to cancel the agent loop.
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
@@ -110,6 +141,75 @@ export default function AgentWorkbench() {
|
|||||||
[audience],
|
[audience],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Pull the caller's session history for the rail (best-effort; silent on fail).
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/agent/sessions?limit=100', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = await res.json();
|
||||||
|
if (Array.isArray(body?.sessions)) setSessions(body.sessions as SessionSummary[]);
|
||||||
|
} catch {
|
||||||
|
/* history is non-critical — leave the rail as-is */
|
||||||
|
}
|
||||||
|
}, [authToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshSessions();
|
||||||
|
}, [refreshSessions]);
|
||||||
|
|
||||||
|
// Restore a past session: load its transcript into the view and adopt its id
|
||||||
|
// so the next turn continues it.
|
||||||
|
const restoreSession = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (running || restoring || id === sessionId) return;
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) {
|
||||||
|
toast.error('未登录或会话已过期');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/agent/sessions/${encodeURIComponent(id)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
const restored: ChatMessage[] = Array.isArray(body?.messages)
|
||||||
|
? (body.messages as ChatMessage[]).map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
text: m.text ?? '',
|
||||||
|
tools: Array.isArray(m.tools) ? m.tools : [],
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const s = body?.session;
|
||||||
|
setMessages(restored);
|
||||||
|
setSessionId(id);
|
||||||
|
setApproval(null);
|
||||||
|
setAsk(null);
|
||||||
|
setUsage(
|
||||||
|
s
|
||||||
|
? {
|
||||||
|
inputTokens: Number(s.input_tokens ?? 0),
|
||||||
|
outputTokens: Number(s.output_tokens ?? 0),
|
||||||
|
totalTokens: Number(s.total_tokens ?? 0),
|
||||||
|
costUsd: Number(s.cost_usd ?? 0),
|
||||||
|
numTurns: Number(s.num_turns ?? 0),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`恢复会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authToken, running, restoring, sessionId, toast],
|
||||||
|
);
|
||||||
|
|
||||||
// Mutate the last assistant message in place as stream events arrive.
|
// Mutate the last assistant message in place as stream events arrive.
|
||||||
const updateAssistant = useCallback((mut: (m: ChatMessage) => void) => {
|
const updateAssistant = useCallback((mut: (m: ChatMessage) => void) => {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
@@ -301,8 +401,10 @@ export default function AgentWorkbench() {
|
|||||||
setRunning(false);
|
setRunning(false);
|
||||||
setApproval(null);
|
setApproval(null);
|
||||||
setAsk(null);
|
setAsk(null);
|
||||||
|
// The turn created/updated this session's row — reflect it in the rail.
|
||||||
|
void refreshSessions();
|
||||||
}
|
}
|
||||||
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]);
|
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant, refreshSessions]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
@@ -327,6 +429,13 @@ export default function AgentWorkbench() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHistory((v) => !v)}
|
||||||
|
style={{ padding: '4px 12px', borderRadius: 6 }}
|
||||||
|
title="历史会话"
|
||||||
|
>
|
||||||
|
{showHistory ? '隐藏历史' : '历史会话'}
|
||||||
|
</button>
|
||||||
<label style={{ fontSize: 12, color: 'var(--text-secondary, #888)' }}>权限模式</label>
|
<label style={{ fontSize: 12, color: 'var(--text-secondary, #888)' }}>权限模式</label>
|
||||||
<select
|
<select
|
||||||
value={mode}
|
value={mode}
|
||||||
@@ -354,42 +463,110 @@ export default function AgentWorkbench() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Main area: history rail + conversation column */}
|
||||||
<div
|
<div style={{ display: 'flex', gap: 12, flex: 1, minHeight: 0 }}>
|
||||||
ref={scrollRef}
|
{showHistory && (
|
||||||
style={{
|
<HistoryRail
|
||||||
flex: 1,
|
sessions={sessions}
|
||||||
overflowY: 'auto',
|
activeId={sessionId}
|
||||||
border: '1px solid var(--border-color, #2a2a2a)',
|
disabled={running || restoring}
|
||||||
borderRadius: 8,
|
onSelect={(id) => void restoreSession(id)}
|
||||||
padding: 12,
|
/>
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 12,
|
|
||||||
minHeight: 240,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13, textAlign: 'center', marginTop: 40 }}>
|
|
||||||
输入任务,让 agent 在隔离沙箱中读写文件、执行命令。危险操作会请求你的审批。
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{messages.map((m, i) => (
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minWidth: 0 }}>
|
||||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
{/* Messages */}
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: m.role === 'user' ? 'var(--primary, #4a9eff)' : '#22c55e' }}>
|
<div
|
||||||
{m.role === 'user' ? '你' : 'Agent'}
|
ref={scrollRef}
|
||||||
</div>
|
style={{
|
||||||
{m.text && (
|
flex: 1,
|
||||||
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.6 }}>{m.text}</div>
|
overflowY: 'auto',
|
||||||
|
border: '1px solid var(--border-color, #2a2a2a)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
minHeight: 240,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13, textAlign: 'center', marginTop: 40 }}>
|
||||||
|
输入任务,让 agent 在隔离沙箱中读写文件、执行命令。危险操作会请求你的审批。
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{m.tools.map((t) => (
|
{messages.map((m, i) => (
|
||||||
<ToolCard key={t.id} tool={t} />
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: m.role === 'user' ? 'var(--primary, #4a9eff)' : '#22c55e' }}>
|
||||||
|
{m.role === 'user' ? '你' : 'Agent'}
|
||||||
|
</div>
|
||||||
|
{m.text && (
|
||||||
|
m.role === 'assistant' ? (
|
||||||
|
<div
|
||||||
|
className="agent-markdown"
|
||||||
|
style={{ fontSize: 14, lineHeight: 1.6 }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.text) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.6 }}>{m.text}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{m.tools.map((t) => (
|
||||||
|
<ToolCard key={t.id} tool={t} />
|
||||||
|
))}
|
||||||
|
{m.role === 'assistant' && !m.text && m.tools.length === 0 && running && i === messages.length - 1 && (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-secondary, #888)' }}>思考中…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{m.role === 'assistant' && !m.text && m.tools.length === 0 && running && i === messages.length - 1 && (
|
</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary, #888)' }}>思考中…</div>
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
disabled={running}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="描述任务… (Ctrl/Cmd + Enter 发送)"
|
||||||
|
rows={3}
|
||||||
|
style={{ flex: 1, padding: 10, borderRadius: 8, resize: 'vertical', fontFamily: 'inherit', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void send()}
|
||||||
|
disabled={running || !input.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: running ? 'var(--border-color, #333)' : 'var(--primary, #4a9eff)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: running ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{running ? '运行中…' : '发送'}
|
||||||
|
</button>
|
||||||
|
{running && (
|
||||||
|
<button
|
||||||
|
onClick={stop}
|
||||||
|
style={{
|
||||||
|
padding: '0 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Approval modal */}
|
{/* Approval modal */}
|
||||||
@@ -408,51 +585,96 @@ export default function AgentWorkbench() {
|
|||||||
onSubmit={(answer) => submitAnswer(ask.requestId, answer)}
|
onSubmit={(answer) => submitAnswer(ask.requestId, answer)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Composer */}
|
function HistoryRail({
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
sessions,
|
||||||
<textarea
|
activeId,
|
||||||
value={input}
|
disabled,
|
||||||
disabled={running}
|
onSelect,
|
||||||
onChange={(e) => setInput(e.target.value)}
|
}: {
|
||||||
onKeyDown={(e) => {
|
sessions: SessionSummary[];
|
||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
activeId: string | null;
|
||||||
e.preventDefault();
|
disabled: boolean;
|
||||||
void send();
|
onSelect: (id: string) => void;
|
||||||
}
|
}) {
|
||||||
}}
|
return (
|
||||||
placeholder="描述任务… (Ctrl/Cmd + Enter 发送)"
|
<div
|
||||||
rows={3}
|
style={{
|
||||||
style={{ flex: 1, padding: 10, borderRadius: 8, resize: 'vertical', fontFamily: 'inherit', fontSize: 14 }}
|
width: 240,
|
||||||
/>
|
flexShrink: 0,
|
||||||
<button
|
display: 'flex',
|
||||||
onClick={() => void send()}
|
flexDirection: 'column',
|
||||||
disabled={running || !input.trim()}
|
border: '1px solid var(--border-color, #2a2a2a)',
|
||||||
style={{
|
borderRadius: 8,
|
||||||
padding: '0 20px',
|
overflow: 'hidden',
|
||||||
borderRadius: 8,
|
}}
|
||||||
background: running ? 'var(--border-color, #333)' : 'var(--primary, #4a9eff)',
|
>
|
||||||
color: '#fff',
|
<div
|
||||||
border: 'none',
|
style={{
|
||||||
cursor: running ? 'default' : 'pointer',
|
padding: '8px 12px',
|
||||||
}}
|
fontSize: 12,
|
||||||
>
|
fontWeight: 600,
|
||||||
{running ? '运行中…' : '发送'}
|
color: 'var(--text-secondary, #888)',
|
||||||
</button>
|
borderBottom: '1px solid var(--border-color, #2a2a2a)',
|
||||||
{running && (
|
}}
|
||||||
<button
|
>
|
||||||
onClick={stop}
|
历史会话 {sessions.length > 0 && `(${sessions.length})`}
|
||||||
style={{
|
</div>
|
||||||
padding: '0 16px',
|
<div style={{ flex: 1, overflowY: 'auto', padding: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
borderRadius: 8,
|
{sessions.length === 0 ? (
|
||||||
background: '#ef4444',
|
<div style={{ fontSize: 12, color: 'var(--text-secondary, #888)', textAlign: 'center', padding: '20px 8px' }}>
|
||||||
color: '#fff',
|
还没有会话记录
|
||||||
border: 'none',
|
</div>
|
||||||
cursor: 'pointer',
|
) : (
|
||||||
}}
|
sessions.map((s) => {
|
||||||
>
|
const active = s.id === activeId;
|
||||||
停止
|
return (
|
||||||
</button>
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => onSelect(s.id)}
|
||||||
|
disabled={disabled && !active}
|
||||||
|
title={s.title ?? s.id}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid transparent',
|
||||||
|
background: active ? 'var(--primary, #4a9eff)' : 'var(--surface, rgba(255,255,255,0.02))',
|
||||||
|
color: active ? '#fff' : 'inherit',
|
||||||
|
cursor: disabled && !active ? 'default' : 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.title?.trim() || `会话 ${s.id.slice(0, 8)}`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: active ? 'rgba(255,255,255,0.8)' : 'var(--text-secondary, #888)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{formatRelative(s.updated_at)}</span>
|
||||||
|
<span>· {s.num_turns} 轮</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { marked } from 'marked';
|
|
||||||
import { getSessionToken } from '../authSession.js';
|
import { getSessionToken } from '../authSession.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { useIsMobile } from '../components/useIsMobile.js';
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
|
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
|
||||||
|
import { renderMarkdown } from '../lib/markdown.js';
|
||||||
|
|
||||||
type AgentRole = 'user' | 'assistant' | 'system';
|
type AgentRole = 'user' | 'assistant' | 'system';
|
||||||
|
|
||||||
@@ -591,79 +591,6 @@ function appendLine(current: string, line: string) {
|
|||||||
return current ? `${current}\n${line}` : line;
|
return current ? `${current}\n${line}` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
|
|
||||||
const value = String(raw || '').trim();
|
|
||||||
if (!value) return null;
|
|
||||||
if (value.startsWith('#') || value.startsWith('/')) return value;
|
|
||||||
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
|
|
||||||
if (/^https?:\/\//i.test(value)) return value;
|
|
||||||
if (/^mailto:/i.test(value) || /^tel:/i.test(value)) return value;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeMarkdownHtml(html: string): string {
|
|
||||||
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
|
|
||||||
return `<p>${escapeHtml(html)}</p>`;
|
|
||||||
}
|
|
||||||
const allowedTags = new Set([
|
|
||||||
'a', 'blockquote', 'br', 'code', 'del', 'em', 'h1', 'h2', 'h3', 'h4',
|
|
||||||
'h5', 'h6', 'hr', 'img', 'li', 'ol', 'p', 'pre', 'strong', 'table',
|
|
||||||
'tbody', 'td', 'th', 'thead', 'tr', 'ul',
|
|
||||||
]);
|
|
||||||
const droppedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
|
|
||||||
const voidTags = new Set(['br', 'hr', 'img']);
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
|
|
||||||
const cleanNode = (node: Node): string => {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) return escapeHtml(node.textContent || '');
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
||||||
const element = node as Element;
|
|
||||||
const tag = element.tagName.toLowerCase();
|
|
||||||
if (droppedTags.has(tag)) return '';
|
|
||||||
const children = Array.from(element.childNodes).map(cleanNode).join('');
|
|
||||||
if (!allowedTags.has(tag)) return children;
|
|
||||||
const attrs: string[] = [];
|
|
||||||
if (tag === 'a') {
|
|
||||||
const href = sanitizeUrl(element.getAttribute('href'));
|
|
||||||
if (href) {
|
|
||||||
attrs.push(` href="${escapeHtml(href)}"`);
|
|
||||||
attrs.push(' target="_blank" rel="noopener noreferrer"');
|
|
||||||
}
|
|
||||||
} else if (tag === 'img') {
|
|
||||||
const src = sanitizeUrl(element.getAttribute('src'), true);
|
|
||||||
if (!src) return '';
|
|
||||||
attrs.push(` src="${escapeHtml(src)}"`);
|
|
||||||
const alt = element.getAttribute('alt');
|
|
||||||
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
|
|
||||||
} else if (tag === 'code') {
|
|
||||||
const className = String(element.getAttribute('class') || '').trim();
|
|
||||||
if (/^language-[a-z0-9_-]+$/i.test(className)) {
|
|
||||||
attrs.push(` class="${escapeHtml(className)}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return voidTags.has(tag)
|
|
||||||
? `<${tag}${attrs.join('')}>`
|
|
||||||
: `<${tag}${attrs.join('')}>${children}</${tag}>`;
|
|
||||||
};
|
|
||||||
return Array.from(doc.body.childNodes).map(cleanNode).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMarkdown(content: string): string {
|
|
||||||
const raw = String(content || '').trim();
|
|
||||||
if (!raw) return '';
|
|
||||||
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
|
|
||||||
return sanitizeMarkdownHtml(rendered || `<p>${escapeHtml(raw)}</p>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function processThinkTags(content: string, reasoningContent = '') {
|
function processThinkTags(content: string, reasoningContent = '') {
|
||||||
if (!content || !/<think>/i.test(content)) {
|
if (!content || !/<think>/i.test(content)) {
|
||||||
return { content, reasoningContent };
|
return { content, reasoningContent };
|
||||||
|
|||||||
Reference in New Issue
Block a user