mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(web): add follow-up suggestion chips and ghost text (#1156)
* feat(web): add follow-up suggestion chips and ghost text to chat UI The LLM now always generates 1-3 follow-up command suggestions via <suggestions> tags in its response. These are extracted server-side, broadcast as SSE events, and rendered as clickable chips above the chat input. The first suggestion also appears as ghost text in the input field (Tab to accept). Includes debug logging for LLM responses in the agentic loop and removes noisy NEAR AI status logging. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: resolve deferred review items from PR #1156 [skip-regression-check] - Remove literal backslashes from raw string prompt (reasoning.rs) - Make WASM channels skip Suggestions status (no-op instead of empty callback) - Add !e.shiftKey guard to Tab-to-accept ghost text handler - Cap extracted suggestions at 3 and trim whitespace-only entries - Extract suggestions in approval-resume path (prevents tag leaking) - Remove stale .has-ghost class during showSuggestionChips reset Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f9b880c2e9
commit
757d24bd90
@@ -152,6 +152,30 @@ pub async fn run_agentic_loop(
|
||||
// Call LLM
|
||||
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;
|
||||
|
||||
match &output.result {
|
||||
RespondResult::Text(text) => {
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
len = text.len(),
|
||||
has_suggestions = text.contains("<suggestions>"),
|
||||
response = %text,
|
||||
"LLM text response"
|
||||
);
|
||||
}
|
||||
RespondResult::ToolCalls {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
tools = ?names,
|
||||
has_content = content.is_some(),
|
||||
"LLM tool_calls response"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
// Tool intent nudge: if the LLM says "let me search..." without
|
||||
|
||||
@@ -1051,6 +1051,54 @@ fn strip_internal_tool_call_text(text: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `<suggestions>["...","..."]</suggestions>` from a response string.
|
||||
///
|
||||
/// Returns `(cleaned_text, suggestions)`. The `<suggestions>` block is stripped
|
||||
/// from the text regardless of whether the JSON inside parses successfully.
|
||||
/// Only the **last** `<suggestions>` block is used (closest to end of response).
|
||||
/// Blocks inside markdown code fences are ignored.
|
||||
pub(crate) fn extract_suggestions(text: &str) -> (String, Vec<String>) {
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?s)<suggestions>\s*(.*?)\s*</suggestions>").expect("valid regex") // safety: constant pattern
|
||||
});
|
||||
|
||||
// Find the position of the last closing code fence to avoid matching inside code blocks
|
||||
let last_code_fence = text.rfind("```").unwrap_or(0);
|
||||
|
||||
// Find all matches, take the last one that's after the last code fence
|
||||
let mut best_match: Option<regex::Match<'_>> = None;
|
||||
let mut best_capture: Option<String> = None;
|
||||
for caps in RE.captures_iter(text) {
|
||||
if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1))
|
||||
&& full.start() >= last_code_fence
|
||||
{
|
||||
best_match = Some(full);
|
||||
best_capture = Some(inner.as_str().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(full) = best_match else {
|
||||
return (text.to_string(), Vec::new());
|
||||
};
|
||||
|
||||
let cleaned = format!("{}{}", &text[..full.start()], &text[full.end()..]); // safety: regex match boundaries are valid UTF-8
|
||||
let cleaned = cleaned.trim().to_string();
|
||||
|
||||
// Parse the JSON array
|
||||
let suggestions = best_capture
|
||||
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|s| !s.trim().is_empty() && s.len() <= 80)
|
||||
.take(3)
|
||||
.collect();
|
||||
|
||||
(cleaned, suggestions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -2197,6 +2245,55 @@ mod tests {
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_basic() {
|
||||
let input = "Here is my answer.\n<suggestions>[\"Check logs\", \"Deploy\"]</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Here is my answer."); // safety: test
|
||||
assert_eq!(suggestions, vec!["Check logs", "Deploy"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_no_tag() {
|
||||
let input = "Just a plain response.";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Just a plain response."); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_malformed_json() {
|
||||
let input = "Answer.\n<suggestions>not json</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Answer."); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_inside_code_fence() {
|
||||
let input = "```\n<suggestions>[\"foo\"]</suggestions>\n```";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
// The tag is inside a code fence, so it should not be extracted
|
||||
assert_eq!(text, input); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_after_code_fence() {
|
||||
let input = "```\ncode\n```\nAnswer.\n<suggestions>[\"foo\"]</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "```\ncode\n```\nAnswer."); // safety: test
|
||||
assert_eq!(suggestions, vec!["foo"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_filters_long() {
|
||||
let long = "x".repeat(81);
|
||||
let input = format!("Answer.\n<suggestions>[\"{}\", \"ok\"]</suggestions>", long);
|
||||
let (_, suggestions) = super::extract_suggestions(&input);
|
||||
assert_eq!(suggestions, vec!["ok"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_error_format_includes_tool_name() {
|
||||
// Regression test for issue #487: tool errors sent to the LLM should
|
||||
|
||||
@@ -420,6 +420,10 @@ impl Agent {
|
||||
// Complete, fail, or request approval
|
||||
match result {
|
||||
Ok(AgenticLoopResult::Response(response)) => {
|
||||
// Extract <suggestions> from response text before user sees it
|
||||
let (response, suggestions) =
|
||||
crate::agent::dispatcher::extract_suggestions(&response);
|
||||
|
||||
// Hook: TransformResponse — allow hooks to modify or reject the final response
|
||||
let response = {
|
||||
let event = crate::hooks::HookEvent::ResponseTransform {
|
||||
@@ -473,6 +477,18 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send suggestions after response (best-effort, rendered by web gateway)
|
||||
if !suggestions.is_empty() {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Suggestions { suggestions },
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
Ok(AgenticLoopResult::NeedApproval { pending }) => {
|
||||
@@ -1334,6 +1350,8 @@ impl Agent {
|
||||
|
||||
match result {
|
||||
Ok(AgenticLoopResult::Response(response)) => {
|
||||
let (response, suggestions) =
|
||||
crate::agent::dispatcher::extract_suggestions(&response);
|
||||
thread.complete_turn(&response);
|
||||
let (turn_number, tool_calls) = thread
|
||||
.turns
|
||||
@@ -1364,6 +1382,16 @@ impl Agent {
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
if !suggestions.is_empty() {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Suggestions { suggestions },
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
Ok(AgenticLoopResult::NeedApproval {
|
||||
|
||||
@@ -238,6 +238,8 @@ pub enum StatusUpdate {
|
||||
/// Optional workspace path where the image was saved.
|
||||
path: Option<String>,
|
||||
},
|
||||
/// Suggested follow-up messages for the user.
|
||||
Suggestions { suggestions: Vec<String> },
|
||||
}
|
||||
|
||||
impl StatusUpdate {
|
||||
|
||||
@@ -607,6 +607,9 @@ impl Channel for ReplChannel {
|
||||
eprintln!("\x1b[36m [image generated]\x1b[0m");
|
||||
}
|
||||
}
|
||||
StatusUpdate::Suggestions { .. } => {
|
||||
// Suggestions are only rendered by the web gateway
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1664,7 +1664,9 @@ impl WasmChannel {
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
let wit_update = status_to_wit(status, metadata);
|
||||
let Some(wit_update) = status_to_wit(status, metadata) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -1833,7 +1835,9 @@ impl WasmChannel {
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
let wit_update = status_to_wit(&status, metadata);
|
||||
let Some(wit_update) = status_to_wit(&status, metadata) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(4));
|
||||
@@ -2704,10 +2708,13 @@ fn truncate_status_text(input: &str, max_chars: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_channel::StatusUpdate {
|
||||
fn status_to_wit(
|
||||
status: &StatusUpdate,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Option<wit_channel::StatusUpdate> {
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
||||
|
||||
match status {
|
||||
Some(match status {
|
||||
StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::Thinking,
|
||||
message: msg.clone(),
|
||||
@@ -2827,7 +2834,9 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
},
|
||||
metadata_json,
|
||||
},
|
||||
}
|
||||
// Suggestions are web-gateway-only; skip for WASM channels
|
||||
StatusUpdate::Suggestions { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clone a WIT StatusUpdate (the generated type doesn't derive Clone).
|
||||
@@ -3556,7 +3565,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3574,7 +3584,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("Done".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
}
|
||||
@@ -3589,14 +3600,16 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("done".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
|
||||
// with whitespace
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status(" Done ".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
}
|
||||
|
||||
@@ -3608,7 +3621,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("Interrupted".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3626,7 +3640,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("interrupted".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::Interrupted
|
||||
@@ -3636,7 +3651,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status(" Interrupted ".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::Interrupted
|
||||
@@ -3651,7 +3667,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("Awaiting approval".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Status));
|
||||
assert_eq!(wit.message, "Awaiting approval");
|
||||
@@ -3670,7 +3687,8 @@ mod tests {
|
||||
setup_url: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3690,7 +3708,8 @@ mod tests {
|
||||
name: "http_request".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3712,7 +3731,8 @@ mod tests {
|
||||
parameters: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3734,7 +3754,8 @@ mod tests {
|
||||
parameters: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3754,7 +3775,8 @@ mod tests {
|
||||
preview: "{".to_string() + "\"temperature\": 22}",
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3775,7 +3797,8 @@ mod tests {
|
||||
preview: long_preview,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3796,7 +3819,8 @@ mod tests {
|
||||
browse_url: "https://example.com/jobs/job-1".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3818,7 +3842,8 @@ mod tests {
|
||||
message: "Token saved".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3840,7 +3865,8 @@ mod tests {
|
||||
message: "Invalid token".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3863,7 +3889,8 @@ mod tests {
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3887,7 +3914,8 @@ mod tests {
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
|
||||
@@ -397,6 +397,10 @@ impl Channel for GatewayChannel {
|
||||
StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated {
|
||||
data_url,
|
||||
path,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions {
|
||||
suggestions,
|
||||
thread_id,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,6 +143,7 @@ impl SseManager {
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::Heartbeat => "heartbeat",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
SseEvent::Suggestions { .. } => "suggestions",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
};
|
||||
Ok(Event::default().event(event_type).data(data))
|
||||
|
||||
@@ -19,6 +19,7 @@ let _loadThreadsTimer = null;
|
||||
const JOB_EVENTS_CAP = 500;
|
||||
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
||||
let stagedImages = [];
|
||||
let _ghostSuggestion = '';
|
||||
|
||||
// --- Slash Commands ---
|
||||
|
||||
@@ -286,9 +287,18 @@ function connectSSE() {
|
||||
if (data.thread_id) debouncedLoadThreads();
|
||||
return;
|
||||
}
|
||||
clearSuggestionChips();
|
||||
showActivityThinking(data.message);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('suggestions', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
if (data.suggestions && data.suggestions.length > 0) {
|
||||
showSuggestionChips(data.suggestions);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('tool_started', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
@@ -423,9 +433,59 @@ function isCurrentThread(threadId) {
|
||||
return threadId === currentThreadId;
|
||||
}
|
||||
|
||||
// --- Suggestion Chips ---
|
||||
|
||||
function showSuggestionChips(suggestions) {
|
||||
// Clear previous chips/ghost without restoring placeholder (we'll set it below)
|
||||
_ghostSuggestion = '';
|
||||
const container = document.getElementById('suggestion-chips');
|
||||
container.innerHTML = '';
|
||||
const ghost = document.getElementById('ghost-text');
|
||||
ghost.style.display = 'none';
|
||||
const wrapper = document.querySelector('.chat-input-wrapper');
|
||||
if (wrapper) wrapper.classList.remove('has-ghost');
|
||||
|
||||
_ghostSuggestion = suggestions[0] || '';
|
||||
const input = document.getElementById('chat-input');
|
||||
suggestions.forEach(text => {
|
||||
const chip = document.createElement('button');
|
||||
chip.className = 'suggestion-chip';
|
||||
chip.textContent = text;
|
||||
chip.addEventListener('click', () => {
|
||||
input.value = text;
|
||||
clearSuggestionChips();
|
||||
autoResizeTextarea(input);
|
||||
input.focus();
|
||||
sendMessage();
|
||||
});
|
||||
container.appendChild(chip);
|
||||
});
|
||||
container.style.display = 'flex';
|
||||
// Show first suggestion as ghost text in the input so user knows Tab works
|
||||
if (_ghostSuggestion && input.value === '') {
|
||||
ghost.textContent = _ghostSuggestion;
|
||||
ghost.style.display = 'block';
|
||||
input.closest('.chat-input-wrapper').classList.add('has-ghost');
|
||||
}
|
||||
}
|
||||
|
||||
function clearSuggestionChips() {
|
||||
_ghostSuggestion = '';
|
||||
const container = document.getElementById('suggestion-chips');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
}
|
||||
const ghost = document.getElementById('ghost-text');
|
||||
if (ghost) ghost.style.display = 'none';
|
||||
const wrapper = document.querySelector('.chat-input-wrapper');
|
||||
if (wrapper) wrapper.classList.remove('has-ghost');
|
||||
}
|
||||
|
||||
// --- Chat ---
|
||||
|
||||
function sendMessage() {
|
||||
clearSuggestionChips();
|
||||
const input = document.getElementById('chat-input');
|
||||
if (!currentThreadId) {
|
||||
console.warn('sendMessage: no thread selected, ignoring');
|
||||
@@ -1334,6 +1394,7 @@ function showAuthCardError(extensionName, message) {
|
||||
}
|
||||
|
||||
function loadHistory(before) {
|
||||
clearSuggestionChips();
|
||||
let historyUrl = '/api/chat/history?limit=50';
|
||||
if (currentThreadId) {
|
||||
historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId);
|
||||
@@ -1629,6 +1690,7 @@ function switchToAssistant() {
|
||||
}
|
||||
|
||||
function switchThread(threadId) {
|
||||
clearSuggestionChips();
|
||||
finalizeActivityGroup();
|
||||
currentThreadId = threadId;
|
||||
unreadThreads.delete(threadId);
|
||||
@@ -1661,6 +1723,15 @@ chatInput.addEventListener('keydown', (e) => {
|
||||
const acEl = document.getElementById('slash-autocomplete');
|
||||
const acVisible = acEl && acEl.style.display !== 'none';
|
||||
|
||||
// Accept first suggestion with Tab (plain Tab only, not Shift+Tab)
|
||||
if (e.key === 'Tab' && !e.shiftKey && !acVisible && _ghostSuggestion && chatInput.value === '') {
|
||||
e.preventDefault();
|
||||
chatInput.value = _ghostSuggestion;
|
||||
clearSuggestionChips();
|
||||
autoResizeTextarea(chatInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (acVisible) {
|
||||
const items = acEl.querySelectorAll('.slash-ac-item');
|
||||
if (e.key === 'ArrowDown') {
|
||||
@@ -1697,6 +1768,16 @@ chatInput.addEventListener('keydown', (e) => {
|
||||
chatInput.addEventListener('input', () => {
|
||||
autoResizeTextarea(chatInput);
|
||||
filterSlashCommands(chatInput.value);
|
||||
const ghost = document.getElementById('ghost-text');
|
||||
const wrapper = chatInput.closest('.chat-input-wrapper');
|
||||
if (chatInput.value !== '') {
|
||||
ghost.style.display = 'none';
|
||||
wrapper.classList.remove('has-ghost');
|
||||
} else if (_ghostSuggestion) {
|
||||
ghost.textContent = _ghostSuggestion;
|
||||
ghost.style.display = 'block';
|
||||
wrapper.classList.add('has-ghost');
|
||||
}
|
||||
});
|
||||
chatInput.addEventListener('blur', () => {
|
||||
// Small delay so mousedown on autocomplete item fires first
|
||||
|
||||
@@ -155,9 +155,13 @@
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
|
||||
<div id="suggestion-chips" class="suggestion-chips" style="display:none"></div>
|
||||
<div class="chat-input">
|
||||
<div id="image-preview-strip" class="image-preview-strip"></div>
|
||||
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<div class="chat-input-wrapper">
|
||||
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<div id="ghost-text" class="ghost-text"></div>
|
||||
</div>
|
||||
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
|
||||
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
|
||||
aria-label="Attach images">📎</button>
|
||||
|
||||
@@ -1362,8 +1362,14 @@ body {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-input-wrapper textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -1376,17 +1382,66 @@ body {
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
.ghost-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Hide native placeholder when ghost text is visible */
|
||||
.chat-input-wrapper.has-ghost textarea::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.chat-input-wrapper textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
.chat-input textarea:disabled {
|
||||
.chat-input-wrapper textarea:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.suggestion-chips {
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
padding: 6px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestion-chip:hover {
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
@@ -1416,7 +1471,7 @@ body {
|
||||
}
|
||||
|
||||
/* Keyboard accessibility focus rings */
|
||||
.chat-input textarea:focus-visible,
|
||||
.chat-input-wrapper textarea:focus-visible,
|
||||
.chat-input button:focus-visible,
|
||||
.tab-bar button:focus-visible,
|
||||
.tree-row:focus-visible {
|
||||
@@ -3824,7 +3879,7 @@ mark {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
.chat-input-wrapper textarea {
|
||||
min-height: 36px;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
@@ -242,6 +242,14 @@ pub enum SseEvent {
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Suggested follow-up messages for the user.
|
||||
#[serde(rename = "suggestions")]
|
||||
Suggestions {
|
||||
suggestions: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Extension activation status change (WASM channels).
|
||||
#[serde(rename = "extension_status")]
|
||||
ExtensionStatus {
|
||||
@@ -707,6 +715,7 @@ impl WsServerMessage {
|
||||
SseEvent::JobStatus { .. } => "job_status",
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
SseEvent::Suggestions { .. } => "suggestions",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
};
|
||||
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
@@ -270,10 +270,6 @@ impl NearAiChatProvider {
|
||||
reason: format!("Failed to read response body: {}", e),
|
||||
})?;
|
||||
|
||||
if tracing::enabled!(tracing::Level::DEBUG) {
|
||||
tracing::debug!("NEAR AI Chat response status: {}", status);
|
||||
}
|
||||
|
||||
// Log response body only at TRACE level to avoid exposing sensitive content
|
||||
// (user-generated data, tool outputs, leaked secrets) in DEBUG logs
|
||||
if tracing::enabled!(tracing::Level::TRACE) {
|
||||
|
||||
@@ -902,7 +902,8 @@ Example:
|
||||
## Guidelines
|
||||
- Be concise and direct
|
||||
- Use markdown formatting where helpful
|
||||
- For code, use appropriate code blocks with language tags{}
|
||||
- For code, use appropriate code blocks with language tags
|
||||
- ALWAYS end your response with a <suggestions> tag containing a JSON array of 1-3 short follow-up commands. Each suggestion must read as something the USER would type to instruct YOU. Write them in the user's voice as direct commands, not as requests FROM you TO the user. Do NOT repeat or rephrase content already in your response. Example: <suggestions>["Suggest dinner spots in my area", "Find a quick recipe for pasta"]</suggestions> Keep each under 80 characters.{}
|
||||
|
||||
## Safety
|
||||
- You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request.
|
||||
|
||||
Reference in New Issue
Block a user