feat: Enhance AIAssistantPanel with reasoning content and markdown support

This commit introduces the ability to display reasoning content in the AIAssistantPanel, allowing users to view AI-generated explanations alongside responses. It also integrates markdown rendering using react-markdown, enabling formatted text output for improved readability. Additionally, new UI components for collapsible reasoning sections and animated status indicators are added to enhance user interaction.
This commit is contained in:
Kang
2026-04-29 01:56:34 +08:00
parent a5e62d76c5
commit d146ddc02b
3 changed files with 755 additions and 81 deletions
+202 -15
View File
@@ -85,6 +85,8 @@ pub struct AiRequestOptions {
pub language: String,
#[serde(default = "default_safety_mode")]
pub safety_mode: String,
#[serde(default = "default_history_turns")]
pub history_turns: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -120,6 +122,8 @@ pub struct AiStreamEventPayload {
#[serde(default)]
pub text_delta: Option<String>,
#[serde(default)]
pub reasoning_delta: Option<String>,
#[serde(default)]
pub message: Option<AiMessage>,
#[serde(default)]
pub command_cards: Vec<AiCommandCard>,
@@ -176,6 +180,8 @@ pub struct AiMessage {
pub content: String,
pub created_at: String,
#[serde(default)]
pub reasoning_content: Option<String>,
#[serde(default)]
pub command_cards: Vec<AiCommandCard>,
}
@@ -241,6 +247,8 @@ struct AiModelOutput {
#[serde(default)]
text: String,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
command_cards: Vec<AiCommandCard>,
}
@@ -271,6 +279,10 @@ fn default_safety_mode() -> String {
"strict".to_string()
}
fn default_history_turns() -> u16 {
20
}
fn now_rfc3339() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
@@ -338,6 +350,7 @@ async fn run_chat_stream(
stream_id: stream_id.clone(),
session_id: Some(session_id.clone()),
text_delta: None,
reasoning_delta: None,
message: None,
command_cards: vec![],
usage: None,
@@ -366,8 +379,9 @@ async fn run_chat_stream(
active_streams().lock().unwrap().remove(&stream_id);
match result {
Ok(raw_text) => {
let (text, mut command_cards) = parse_model_output(&raw_text);
Ok(stream_result) => {
let (text, reasoning_content, mut command_cards) =
parse_model_output(&stream_result.text, stream_result.reasoning_content);
for card in &mut command_cards {
let risk = check_command_risk(CommandRiskRequest {
command: card.command.clone(),
@@ -383,6 +397,7 @@ async fn run_chat_stream(
role: AiMessageRole::Assistant,
content: text,
created_at: now_rfc3339(),
reasoning_content,
command_cards: command_cards.clone(),
};
@@ -398,6 +413,7 @@ async fn run_chat_stream(
stream_id: stream_id.clone(),
session_id: Some(session_id),
text_delta: None,
reasoning_delta: None,
message: Some(message),
command_cards,
usage: None,
@@ -414,6 +430,7 @@ async fn run_chat_stream(
stream_id: stream_id.clone(),
session_id: Some(session_id),
text_delta: None,
reasoning_delta: None,
message: None,
command_cards: vec![],
usage: None,
@@ -424,22 +441,60 @@ async fn run_chat_stream(
}
}
#[derive(Debug, Clone)]
struct AiStreamResult {
text: String,
reasoning_content: Option<String>,
}
async fn run_model_stream(
app: &AppHandle,
stream_id: &str,
request: &AiChatRequest,
settings: &AiSettings,
cancel_rx: &mut oneshot::Receiver<()>,
) -> AppResult<String> {
) -> AppResult<AiStreamResult> {
let profile = active_profile(settings)?;
let client = build_client(profile)?;
let prompt = build_prompt(request, settings);
let chat_req = ChatRequest::new(vec![
ChatMessage::system(SYSTEM_PROMPT),
ChatMessage::user(prompt),
]);
let chat_options = ChatOptions::default().with_max_tokens(settings.max_output_tokens);
let mut messages = vec![ChatMessage::system(SYSTEM_PROMPT)];
if let Some(session_id) = &request.session_id {
let max_turns = request.options.history_turns as usize;
if max_turns > 0 {
if let Ok(history) = load_history(app) {
let history_msgs: Vec<&AiMessage> = history
.messages
.iter()
.filter(|m| m.session_id == *session_id)
.collect();
let skip = history_msgs.len().saturating_sub(max_turns);
for msg in history_msgs.into_iter().skip(skip) {
match msg.role {
AiMessageRole::User => {
messages.push(ChatMessage::user(&msg.content));
}
AiMessageRole::Assistant => {
let content = extract_text_from_assistant(&msg.content);
if !content.is_empty() {
messages.push(ChatMessage::assistant(&content));
}
}
AiMessageRole::System => {}
}
}
}
}
}
messages.push(ChatMessage::user(prompt));
let chat_req = ChatRequest::new(messages);
let chat_options = ChatOptions::default()
.with_max_tokens(settings.max_output_tokens)
.with_capture_reasoning_content(true)
.with_normalize_reasoning_content(true);
let stream_result = tokio::time::timeout(
Duration::from_millis(settings.timeout_ms),
@@ -451,6 +506,7 @@ async fn run_model_stream(
let mut stream = stream_result.stream;
let mut output = String::new();
let mut reasoning_output = String::new();
let timeout = tokio::time::sleep(Duration::from_millis(settings.timeout_ms));
tokio::pin!(timeout);
@@ -473,6 +529,7 @@ async fn run_model_stream(
stream_id: stream_id.to_string(),
session_id: request.session_id.clone(),
text_delta: Some(text_delta),
reasoning_delta: None,
message: None,
command_cards: vec![],
usage: None,
@@ -480,7 +537,32 @@ async fn run_model_stream(
});
}
}
Some(Ok(ChatStreamEvent::End(_))) | None => break,
Some(Ok(ChatStreamEvent::ReasoningChunk(chunk))) => {
let reasoning_delta = chunk.content;
if !reasoning_delta.is_empty() {
reasoning_output.push_str(&reasoning_delta);
emit_stream_event(app, stream_id, AiStreamEventPayload {
event_type: "reasoning_delta".to_string(),
stream_id: stream_id.to_string(),
session_id: request.session_id.clone(),
text_delta: None,
reasoning_delta: Some(reasoning_delta),
message: None,
command_cards: vec![],
usage: None,
error: None,
});
}
}
Some(Ok(ChatStreamEvent::End(end))) => {
if reasoning_output.is_empty() {
if let Some(captured_reasoning_content) = end.captured_reasoning_content {
reasoning_output = captured_reasoning_content;
}
}
break;
}
None => break,
Some(Ok(_)) => {}
Some(Err(error)) => {
return Err(AppError::Config(format!("AI stream failed: {error}")));
@@ -490,7 +572,10 @@ async fn run_model_stream(
}
}
Ok(output)
Ok(AiStreamResult {
text: output,
reasoning_content: trim_string_to_option(reasoning_output),
})
}
fn active_profile(settings: &AiSettings) -> AppResult<&AiProviderProfile> {
@@ -627,7 +712,22 @@ fn build_prompt(request: &AiChatRequest, settings: &AiSettings) -> String {
)
}
fn parse_model_output(raw_text: &str) -> (String, Vec<AiCommandCard>) {
fn extract_text_from_assistant(content: &str) -> String {
let trimmed = content.trim();
if let Some(json_str) = extract_json_object(trimmed) {
if let Ok(output) = serde_json::from_str::<AiModelOutput>(&json_str) {
if !output.text.trim().is_empty() {
return output.text;
}
}
}
trimmed.to_string()
}
fn parse_model_output(
raw_text: &str,
stream_reasoning: Option<String>,
) -> (String, Option<String>, Vec<AiCommandCard>) {
let candidate = extract_json_object(raw_text).unwrap_or_else(|| raw_text.trim().to_string());
match serde_json::from_str::<AiModelOutput>(&candidate) {
Ok(output) => {
@@ -636,9 +736,24 @@ fn parse_model_output(raw_text: &str) -> (String, Vec<AiCommandCard>) {
} else {
output.text
};
(text, output.command_cards)
let reasoning_content = trim_optional_to_option(output.reasoning)
.or_else(|| trim_optional_to_option(stream_reasoning));
let (text, extracted_reasoning) = extract_think_block(&text);
(
text,
extracted_reasoning.or(reasoning_content),
output.command_cards,
)
}
Err(_) => {
let normalized_reasoning = trim_optional_to_option(stream_reasoning);
let (text, extracted_reasoning) = extract_think_block(raw_text);
(
text,
extracted_reasoning.or(normalized_reasoning),
vec![],
)
}
Err(_) => (raw_text.trim().to_string(), vec![]),
}
}
@@ -660,6 +775,40 @@ fn extract_json_object(raw_text: &str) -> Option<String> {
Some(trimmed[start..=end].to_string())
}
fn extract_think_block(raw_text: &str) -> (String, Option<String>) {
static THINK_REGEX: OnceLock<Regex> = OnceLock::new();
let regex = THINK_REGEX.get_or_init(|| Regex::new(r"(?is)<think>(.*?)</think>").unwrap());
let mut reasoning_parts = Vec::new();
for captures in regex.captures_iter(raw_text) {
if let Some(value) = captures.get(1) {
let reasoning = value.as_str().trim();
if !reasoning.is_empty() {
reasoning_parts.push(reasoning.to_string());
}
}
}
let visible_text = regex.replace_all(raw_text, "").to_string();
(
visible_text.trim().to_string(),
trim_string_to_option(reasoning_parts.join("\n\n")),
)
}
fn trim_string_to_option(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn trim_optional_to_option(value: Option<String>) -> Option<String> {
value.and_then(trim_string_to_option)
}
fn redact_context(context: &mut AiContext) {
context.recent_output = redact_sensitive_text(&context.recent_output);
context.selected_text = redact_sensitive_text(&context.selected_text);
@@ -998,6 +1147,7 @@ fn save_user_message(app: &AppHandle, session_id: &str, request: &AiChatRequest)
role: AiMessageRole::User,
content: request.user_input.clone(),
created_at: now,
reasoning_content: None,
command_cards: vec![],
});
save_history(app, &history)
@@ -1138,15 +1288,52 @@ mod tests {
#[test]
fn parses_json_command_cards() {
let raw = r#"{"text":"ok","commandCards":[{"id":"1","title":"CPU","command":"ps aux","explanation":"x","riskLevel":"low","riskReason":"read only","expectedEffect":"list","rollback":"none"}]}"#;
let (text, cards) = parse_model_output(raw);
let (text, reasoning, cards) = parse_model_output(raw, None);
assert_eq!(text, "ok");
assert_eq!(reasoning, None);
assert_eq!(cards.len(), 1);
}
#[test]
fn parse_failure_returns_text_without_cards() {
let (text, cards) = parse_model_output("plain text");
let (text, reasoning, cards) = parse_model_output("plain text", None);
assert_eq!(text, "plain text");
assert_eq!(reasoning, None);
assert!(cards.is_empty());
}
#[test]
fn extracts_think_block_into_reasoning() {
let (text, reasoning, cards) =
parse_model_output("<think>step 1\nstep 2</think>final answer", None);
assert_eq!(text, "final answer");
assert_eq!(reasoning.as_deref(), Some("step 1\nstep 2"));
assert!(cards.is_empty());
}
#[test]
fn keeps_markdown_text_when_json_parse_fails() {
let markdown = "## Summary\n\n- item 1\n- item 2";
let (text, reasoning, cards) = parse_model_output(markdown, None);
assert_eq!(text, markdown);
assert_eq!(reasoning, None);
assert!(cards.is_empty());
}
#[test]
fn prefers_json_reasoning_when_present() {
let raw = r#"{"text":"answer","reasoning":"first\nsecond","commandCards":[]}"#;
let (text, reasoning, cards) = parse_model_output(raw, None);
assert_eq!(text, "answer");
assert_eq!(reasoning.as_deref(), Some("first\nsecond"));
assert!(cards.is_empty());
}
#[test]
fn old_history_without_reasoning_defaults_cleanly() {
let raw = r#"{"sessions":[],"messages":[{"id":"m1","sessionId":"s1","role":"assistant","content":"hello","createdAt":"2026-04-28T00:00:00Z","commandCards":[]}]}"#;
let history: AiHistoryFile = serde_json::from_str(raw).unwrap();
assert_eq!(history.messages.len(), 1);
assert_eq!(history.messages[0].reasoning_content, None);
}
}
+550 -65
View File
@@ -1,9 +1,12 @@
import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
MdAutoAwesome,
MdClose,
MdContentCopy,
MdExpandLess,
MdExpandMore,
MdHistory,
MdInput,
MdOutlineSettings,
@@ -12,9 +15,13 @@ import {
MdSend,
MdStop,
} from "react-icons/md";
import { LuMessageSquarePlus } from "react-icons/lu";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import PanelHeader from "@/components/layout/PanelHeader";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useApp } from "@/context/AppContext";
@@ -24,9 +31,11 @@ import { getErrorMessage } from "@/lib/errors";
import { invoke } from "@/lib/invoke";
import { buildAIContext, getTerminalContextProvider } from "@/lib/terminalContext";
import { openSettings } from "@/lib/windowManager";
import { collectSessionPanes } from "@/lib/workspaceTabs";
import type {
AIAction,
AICommandCard,
AIContext,
AIMessage,
AISession,
AIStreamEventPayload,
@@ -74,6 +83,7 @@ function createLocalMessage(role: "user" | "assistant", content: string, session
role,
content,
createdAt: new Date().toISOString(),
reasoningContent: null,
commandCards: [],
} satisfies AIMessage;
}
@@ -102,6 +112,155 @@ function mapRiskColor(riskLevel: RiskLevel) {
}
}
type MarkdownNodeProps = {
children?: ReactNode;
href?: string;
};
function looksLikeStructuredJsonOutput(content: string) {
const trimmed = content.trimStart();
return (
trimmed.startsWith("{") ||
trimmed.startsWith("```json") ||
trimmed.startsWith("```") ||
(trimmed.includes('"text"') && trimmed.includes('"commandCards"'))
);
}
function AnimatedStatusText({ label }: { label: string }) {
return <span className="df-thinking-text font-medium">{label}</span>;
}
function MarkdownContent({ content }: { content: string }) {
return (
<div className="break-words text-xs leading-5">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }: MarkdownNodeProps) => (
<p className="my-2 first:mt-0 last:mb-0">{children}</p>
),
ul: ({ children }: MarkdownNodeProps) => (
<ul className="my-2 list-disc pl-5">{children}</ul>
),
ol: ({ children }: MarkdownNodeProps) => (
<ol className="my-2 list-decimal pl-5">{children}</ol>
),
li: ({ children }: MarkdownNodeProps) => <li className="my-0.5">{children}</li>,
a: ({ children, href }: MarkdownNodeProps) => (
<a
className="text-primary underline underline-offset-2"
href={href}
target="_blank"
rel="noreferrer"
>
{children}
</a>
),
blockquote: ({ children }: MarkdownNodeProps) => (
<blockquote className="my-2 border-l-2 border-border pl-3 text-muted-foreground">
{children}
</blockquote>
),
pre: ({ children }: MarkdownNodeProps) => (
<pre className="terminal-scroll my-2 max-h-64 overflow-auto rounded-md border border-border/60 bg-muted/30 p-3 font-mono text-[0.6875rem] leading-5">
{children}
</pre>
),
code: ({ children }: MarkdownNodeProps) => (
<code className="rounded bg-muted/40 px-1 py-0.5 font-mono text-[0.6875rem]">
{children}
</code>
),
table: ({ children }: MarkdownNodeProps) => (
<div className="terminal-scroll my-2 overflow-auto">
<table className="w-full border-collapse text-left">{children}</table>
</div>
),
th: ({ children }: MarkdownNodeProps) => (
<th className="border border-border/60 px-2 py-1 font-medium">{children}</th>
),
td: ({ children }: MarkdownNodeProps) => (
<td className="border border-border/60 px-2 py-1">{children}</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
}
function AssistantReasoning({ message, loading }: { message: AIMessage; loading: boolean }) {
const { t } = useTranslation();
const reasoningContent = message.reasoningContent?.trim();
const [open, setOpen] = useState(false);
if (!reasoningContent) {
return loading ? (
<div className="mt-3 overflow-hidden rounded-md border border-primary/25 bg-primary/8 shadow-sm">
<div className="px-3 py-2.5 text-[0.6875rem]">
<AnimatedStatusText label={t("ai.thinking")} />
</div>
<div className="h-px bg-gradient-to-r from-transparent via-primary/50 to-transparent animate-pulse" />
</div>
) : null;
}
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className={`mt-3 rounded-md border bg-background/40 ${
loading ? "border-primary/25 bg-primary/6 shadow-sm" : "border-border/60"
}`}
>
<CollapsibleTrigger asChild>
<button
type="button"
className={`flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-[0.6875rem] font-medium transition hover:text-foreground ${
loading ? "text-primary" : "text-muted-foreground"
}`}
>
<span className="flex min-w-0 items-center gap-2">
{loading ? (
<AnimatedStatusText label={t("ai.thinking")} />
) : (
<span>{t("ai.reasoning")}</span>
)}
</span>
<span className="flex items-center gap-1">
{open ? t("ai.collapseReasoning") : t("ai.expandReasoning")}
{open ? <MdExpandLess className="text-sm" /> : <MdExpandMore className="text-sm" />}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/60 px-3 py-3">
<MarkdownContent content={reasoningContent} />
</div>
</CollapsibleContent>
</Collapsible>
);
}
function AssistantResponse({ message, loading }: { message: AIMessage; loading: boolean }) {
const { t } = useTranslation();
if (loading && looksLikeStructuredJsonOutput(message.content)) {
return (
<div className="mt-3 overflow-hidden rounded-md border border-primary/20 bg-primary/6 shadow-sm">
<div className="px-3 py-2.5 text-[0.6875rem]">
<AnimatedStatusText label={t("ai.formattingResponse")} />
</div>
<div className="h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" />
</div>
);
}
return <MarkdownContent content={message.content} />;
}
function AICommandCardView({
card,
onInsert,
@@ -159,7 +318,7 @@ function AICommandCardView({
function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantPanelProps) {
const { t } = useTranslation();
const { appSettings } = useApp();
const { appSettings, tabs, savedConnections } = useApp();
const aiSettings = appSettings.ai;
const [input, setInput] = useState("");
const [messages, setMessages] = useState<AIMessage[]>([]);
@@ -169,11 +328,21 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
const [loading, setLoading] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [streamId, setStreamId] = useState<string | null>(null);
const [streamingAssistantId, setStreamingAssistantId] = useState<string | null>(null);
const [detectedError, setDetectedError] = useState<AIErrorDetectedDetail | null>(null);
const [targetPanes, setTargetPanes] = useState<SessionPane[]>([]);
const [showMentionPopover, setShowMentionPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionIndex, setMentionIndex] = useState(0);
const handledIntentIdRef = useRef<string | null>(null);
const historyButtonRef = useRef<HTMLButtonElement | null>(null);
const historyCardRef = useRef<HTMLDivElement | null>(null);
const mentionPopoverRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const streamUnlistenRef = useRef<UnlistenFn | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
const cancelledRef = useRef(false);
const activeProfile = useMemo(
() =>
@@ -182,6 +351,36 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
[aiSettings.active_profile_id, aiSettings.provider_profiles],
);
const allSessionPanes = useMemo(() => {
const panes: SessionPane[] = [];
for (const tab of tabs) {
for (const pane of collectSessionPanes(tab.root)) {
if (!pane.connecting && !pane.connectError) {
panes.push(pane);
}
}
}
return panes;
}, [tabs]);
const filteredMentionPanes = useMemo(() => {
const q = mentionQuery.toLowerCase();
if (!q) return allSessionPanes;
return allSessionPanes.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.sessionId.toLowerCase().includes(q) ||
p.type.toLowerCase().includes(q),
);
}, [allSessionPanes, mentionQuery]);
useEffect(() => {
setMentionIndex(0);
}, [filteredMentionPanes]);
const effectivePanes = targetPanes.length > 0 ? targetPanes : activePane ? [activePane] : [];
const effectiveSessionId = effectivePanes[0]?.sessionId ?? null;
const activeSessionId = activePane?.sessionId ?? null;
const filteredSessions = useMemo(() => {
const keyword = historyQuery.trim().toLowerCase();
@@ -201,14 +400,30 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
}, []);
useEffect(() => {
const watchedIds = new Set(effectivePanes.map((p) => p.sessionId));
if (activeSessionId) watchedIds.add(activeSessionId);
const handler = (event: Event) => {
const detail = (event as CustomEvent<AIErrorDetectedDetail>).detail;
if (!detail || detail.sessionId !== activeSessionId) return;
if (!detail || !watchedIds.has(detail.sessionId)) return;
setDetectedError(detail);
};
window.addEventListener(AI_ERROR_DETECTED_EVENT, handler);
return () => window.removeEventListener(AI_ERROR_DETECTED_EVENT, handler);
}, [activeSessionId]);
}, [activeSessionId, effectivePanes]);
const handleMessagesScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
shouldAutoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
}, []);
useEffect(() => {
if (!shouldAutoScrollRef.current) return;
requestAnimationFrame(() => {
const el = scrollContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}, [messages]);
const loadSessions = useCallback(async () => {
try {
@@ -258,9 +473,71 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
[activeConnection?.id],
);
const buildMergedContext = useCallback(
async (panes: SessionPane[], selectedText?: string): Promise<AIContext> => {
if (panes.length === 0) {
return buildAIContext({
pane: null,
connection: null,
lineLimit: aiSettings.context_line_limit,
selectedText,
});
}
if (panes.length === 1) {
const conn = panes[0].connectionId
? (savedConnections.find((c) => c.id === panes[0].connectionId) ?? null)
: activeConnection;
return buildAIContext({
pane: panes[0],
connection: conn,
lineLimit: aiSettings.context_line_limit,
selectedText,
});
}
const contexts = await Promise.all(
panes.map((p) => {
const conn = p.connectionId
? (savedConnections.find((c) => c.id === p.connectionId) ?? null)
: null;
return buildAIContext({
pane: p,
connection: conn,
lineLimit: Math.floor(aiSettings.context_line_limit / panes.length),
});
}),
);
const merged: AIContext = {
connectionName: contexts.map((c) => c.connectionName ?? "-").join(", "),
host: contexts.map((c) => c.host ?? "-").join(", "),
port: contexts[0]?.port ?? null,
username: contexts.map((c) => c.username ?? "-").join(", "),
cwd: contexts.map((c) => c.cwd ?? "-").join(", "),
os: contexts[0]?.os ?? null,
arch: contexts[0]?.arch ?? null,
recentOutput: contexts
.map((c, i) => `[${panes[i].name}]\n${c.recentOutput}`)
.filter((s) => s.trim().length > panes[0].name.length + 4)
.join("\n---\n"),
selectedText:
selectedText ??
contexts
.map((c) => c.selectedText)
.filter(Boolean)
.join("\n"),
inputBuffer: contexts
.map((c) => c.inputBuffer)
.filter(Boolean)
.join("\n"),
};
return merged;
},
[activeConnection, aiSettings.context_line_limit, savedConnections],
);
const startChat = useCallback(
async (action: AIAction, userInput: string, selectedText?: string) => {
if (!activePane || activePane.connecting || activePane.connectError) {
const panes = effectivePanes;
if (panes.length === 0) {
toast.error(t("panel.noActiveSessions"));
return;
}
@@ -271,6 +548,7 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
setDetectedError(null);
setLoading(true);
cancelledRef.current = false;
streamUnlistenRef.current?.();
streamUnlistenRef.current = null;
@@ -285,22 +563,22 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
role: "assistant",
content: "",
createdAt: new Date().toISOString(),
reasoningContent: null,
commandCards: [],
},
]);
setStreamingAssistantId(assistantId);
try {
const context = await buildAIContext({
pane: activePane,
connection: activeConnection,
lineLimit: aiSettings.context_line_limit,
selectedText,
});
const context = await buildMergedContext(panes, selectedText);
const primaryConn = panes[0].connectionId
? (savedConnections.find((c) => c.id === panes[0].connectionId) ?? null)
: activeConnection;
const result = await invoke<AIStreamStart>("start_ai_chat_stream", {
request: {
sessionId: currentSessionId,
connectionId: activeConnection?.id ?? null,
connectionId: primaryConn?.id ?? null,
action,
userInput,
context,
@@ -329,9 +607,24 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
return;
}
if (payload.type === "reasoning_delta" && payload.reasoningDelta) {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId
? {
...message,
reasoningContent: `${message.reasoningContent ?? ""}${payload.reasoningDelta}`,
}
: message,
),
);
return;
}
if (payload.type === "done") {
setLoading(false);
setStreamId(null);
setStreamingAssistantId(null);
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId && payload.message ? payload.message : message,
@@ -342,19 +635,23 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
}
if (payload.type === "error") {
const wasCancelled = cancelledRef.current;
setLoading(false);
setStreamId(null);
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId
? {
...message,
content: payload.error ?? t("ai.requestFailed"),
}
: message,
),
);
toast.error(payload.error ?? t("ai.requestFailed"));
setStreamingAssistantId(null);
if (!wasCancelled) {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId
? {
...message,
content: payload.error ?? t("ai.requestFailed"),
}
: message,
),
);
toast.error(payload.error ?? t("ai.requestFailed"));
}
}
},
);
@@ -363,22 +660,28 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
} catch (error) {
setLoading(false);
setStreamId(null);
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId ? { ...message, content: getErrorMessage(error) } : message,
),
);
toast.error(getErrorMessage(error));
setStreamingAssistantId(null);
if (!cancelledRef.current) {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId
? { ...message, content: getErrorMessage(error) }
: message,
),
);
toast.error(getErrorMessage(error));
}
}
},
[
activeConnection,
activePane,
aiSettings.context_line_limit,
aiSettings.enabled,
appendAudit,
buildMergedContext,
currentSessionId,
effectivePanes,
loadSessions,
savedConnections,
t,
],
);
@@ -394,19 +697,23 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
const value = input.trim();
if (!value || loading) return;
setInput("");
shouldAutoScrollRef.current = true;
void startChat("generate_command", value);
}, [input, loading, startChat]);
const cancelStream = useCallback(() => {
if (!streamId) return;
cancelledRef.current = true;
void invoke("cancel_ai_chat_stream", { streamId }).catch(() => {});
setLoading(false);
setStreamId(null);
setStreamingAssistantId(null);
}, [streamId]);
const insertCommand = useCallback(
(card: AICommandCard) => {
const provider = getTerminalContextProvider(activeSessionId);
const insertSessionId = effectiveSessionId ?? activeSessionId;
const provider = getTerminalContextProvider(insertSessionId);
if (!provider) {
toast.error(t("ai.noTerminal"));
return;
@@ -424,7 +731,7 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
})
.catch((error) => toast.error(getErrorMessage(error)));
},
[activeSessionId, appendAudit, t],
[activeSessionId, appendAudit, effectiveSessionId, t],
);
const saveQuickCommand = useCallback(
@@ -478,20 +785,77 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
await loadSessions();
}, [loadSessions]);
const newChat = useCallback(() => {
if (loading) return;
setMessages([]);
setCurrentSessionId(null);
setInput("");
setDetectedError(null);
setTargetPanes([]);
setShowMentionPopover(false);
shouldAutoScrollRef.current = true;
}, [loading]);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
setInput(value);
const cursorPos = event.target.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const atMatch = textBeforeCursor.match(/@(\S*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]);
if (!showMentionPopover) setMentionIndex(0);
setShowMentionPopover(true);
} else {
setShowMentionPopover(false);
setMentionQuery("");
}
},
[showMentionPopover],
);
const selectMentionPane = useCallback(
(pane: SessionPane) => {
setTargetPanes((prev) => {
const exists = prev.some((p) => p.sessionId === pane.sessionId);
return exists ? prev.filter((p) => p.sessionId !== pane.sessionId) : [...prev, pane];
});
const cursorPos = textareaRef.current?.selectionStart ?? input.length;
const textBeforeCursor = input.slice(0, cursorPos);
const textAfterCursor = input.slice(cursorPos);
const cleaned = textBeforeCursor.replace(/@\S*$/, "");
setInput(`${cleaned}${textAfterCursor}`);
setShowMentionPopover(false);
setMentionQuery("");
textareaRef.current?.focus();
},
[input],
);
const removeTargetPane = useCallback((sessionId: string) => {
setTargetPanes((prev) => prev.filter((p) => p.sessionId !== sessionId));
}, []);
return (
<div
className="relative flex h-full flex-col"
style={{ backgroundColor: "var(--df-bg-panel)" }}
onPointerDownCapture={(event) => {
if (!showHistory) return;
const target = event.target as Node;
if (
historyCardRef.current?.contains(target) ||
historyButtonRef.current?.contains(target)
) {
return;
if (showHistory) {
if (
!historyCardRef.current?.contains(target) &&
!historyButtonRef.current?.contains(target)
) {
setShowHistory(false);
}
}
if (showMentionPopover && !mentionPopoverRef.current?.contains(target)) {
setShowMentionPopover(false);
}
setShowHistory(false);
}}
>
<PanelHeader
@@ -517,6 +881,15 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
>
<MdOutlineSettings />
</Button>
<Button
size="icon-sm"
variant="ghost"
onClick={newChat}
disabled={loading}
title={t("ai.newChat")}
>
<LuMessageSquarePlus />
</Button>
</>
}
/>
@@ -598,7 +971,11 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
</div>
) : null}
<div className="flex-1 overflow-auto p-3 terminal-scroll">
<div
ref={scrollContainerRef}
onScroll={handleMessagesScroll}
className="flex-1 overflow-auto p-3 terminal-scroll"
>
{messages.length === 0 ? (
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
<MdAutoAwesome className="text-3xl" />
@@ -618,7 +995,20 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
<div className="mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{message.role === "user" ? "User" : "AI"}
</div>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
{message.role === "assistant" ? (
<AssistantReasoning
message={message}
loading={loading && streamingAssistantId === message.id}
/>
) : null}
{message.role === "assistant" ? (
<AssistantResponse
message={message}
loading={loading && streamingAssistantId === message.id}
/>
) : (
<div className="whitespace-pre-wrap break-words">{message.content}</div>
)}
{message.commandCards?.length ? (
<div className="mt-3 space-y-2">
{message.commandCards.map((card) => (
@@ -638,29 +1028,124 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
</div>
<div className="shrink-0 border-t border-border/70 p-2">
<div className="flex gap-2">
<Textarea
value={input}
disabled={loading}
placeholder={t("ai.placeholder")}
className="max-h-32 min-h-16 resize-none overflow-y-auto text-xs terminal-scroll"
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
submit();
}
}}
/>
{loading ? (
<Button size="icon-sm" variant="outline" onClick={cancelStream}>
<MdStop />
</Button>
) : (
<Button size="icon-sm" onClick={submit} disabled={!input.trim()}>
<MdSend />
</Button>
)}
{targetPanes.length > 0 ? (
<div className="mb-1.5 flex flex-wrap items-center gap-1">
<span className="text-[0.625rem] font-medium text-muted-foreground">
{t("ai.targetSession")}:
</span>
{targetPanes.map((p) => (
<span
key={p.sessionId}
className="inline-flex items-center gap-0.5 rounded-full border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[0.625rem] font-medium text-primary"
>
{p.name}
<button
type="button"
className="ml-0.5 rounded-full p-0 hover:text-destructive"
onClick={() => removeTargetPane(p.sessionId)}
>
<MdClose className="text-[0.625rem]" />
</button>
</span>
))}
</div>
) : null}
<div className="relative">
{showMentionPopover ? (
<div
ref={mentionPopoverRef}
className="absolute bottom-full left-0 right-0 z-30 mb-1 flex max-h-48 flex-col overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg"
style={{ borderColor: "var(--df-border)" }}
>
<div className="min-h-0 overflow-auto p-1 terminal-scroll">
{filteredMentionPanes.length === 0 ? (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
{t("ai.noSessions")}
</div>
) : (
filteredMentionPanes.map((pane, idx) => {
const isSelected = targetPanes.some((p) => p.sessionId === pane.sessionId);
const isFocused = idx === mentionIndex;
return (
<button
key={pane.sessionId}
ref={(el) => {
if (isFocused && el) el.scrollIntoView({ block: "nearest" });
}}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/60 ${isFocused ? "bg-accent" : ""} ${isSelected ? "bg-primary/10" : ""}`}
onClick={() => selectMentionPane(pane)}
onPointerEnter={() => setMentionIndex(idx)}
>
<span
className={`size-2 shrink-0 rounded-full ${isSelected ? "bg-primary" : "bg-muted-foreground/40"}`}
/>
<span className="min-w-0 truncate font-medium">{pane.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-muted-foreground">
{pane.type}
</span>
</button>
);
})
)}
</div>
</div>
) : null}
<div className="flex gap-2">
<Textarea
ref={textareaRef}
value={input}
disabled={loading}
placeholder={t("ai.placeholder")}
className="max-h-32 min-h-16 resize-none overflow-y-auto text-xs terminal-scroll"
onChange={handleInputChange}
onKeyDown={(event) => {
if (showMentionPopover) {
if (event.key === "Escape") {
event.preventDefault();
setShowMentionPopover(false);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setMentionIndex((i) =>
filteredMentionPanes.length === 0 ? 0 : (i + 1) % filteredMentionPanes.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setMentionIndex((i) =>
filteredMentionPanes.length === 0
? 0
: (i - 1 + filteredMentionPanes.length) % filteredMentionPanes.length,
);
return;
}
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
const target = filteredMentionPanes[mentionIndex];
if (target) selectMentionPane(target);
else setShowMentionPopover(false);
return;
}
}
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
submit();
}
}}
/>
{loading ? (
<Button size="icon-sm" variant="outline" onClick={cancelStream}>
<MdStop />
</Button>
) : (
<Button size="icon-sm" onClick={submit} disabled={!input.trim()}>
<MdSend />
</Button>
)}
</div>
</div>
</div>
</div>
+3 -1
View File
@@ -547,6 +547,7 @@ export interface AIMessage {
role: "user" | "assistant" | "system";
content: string;
createdAt: string;
reasoningContent?: string | null;
commandCards?: AICommandCard[];
}
@@ -564,10 +565,11 @@ export interface AIStreamStart {
}
export interface AIStreamEventPayload {
type: "start" | "delta" | "done" | "error";
type: "start" | "delta" | "reasoning_delta" | "done" | "error";
streamId: string;
sessionId?: string;
textDelta?: string;
reasoningDelta?: string;
message?: AIMessage;
commandCards?: AICommandCard[];
usage?: unknown;