mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
fix: add image support for Kiro channel (fixes #60)
Add image handling for both Anthropic and OpenAI request translators
when converting to CodeWhisperer format.
Changes:
- Anthropic translator: Parse image blocks from message content and
convert to CWImage format (format + base64 data)
- OpenAI translator: Add get_images() method to ChatMessage to extract
images from data URLs in image_url content parts
- Both translators now pass images through ProcessedMessage to
UserInputMessage for current message and history
- Fix unused imports in frontend files
Image format: { format: "jpeg", source: { bytes: "base64_data" } }
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,41 @@ impl ChatMessage {
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取消息中的图片 URL 列表
|
||||
/// 返回 (format, base64_data) 元组列表
|
||||
pub fn get_images(&self) -> Vec<(String, String)> {
|
||||
match &self.content {
|
||||
Some(MessageContent::Parts(parts)) => parts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
if let ContentPart::ImageUrl { image_url } = p {
|
||||
// 解析 data URL: data:image/jpeg;base64,xxxxx
|
||||
if image_url.url.starts_with("data:") {
|
||||
let parts: Vec<&str> = image_url.url.splitn(2, ',').collect();
|
||||
if parts.len() == 2 {
|
||||
// 提取 media_type: data:image/jpeg;base64 -> image/jpeg
|
||||
let header = parts[0];
|
||||
let data = parts[1];
|
||||
let media_type = header
|
||||
.strip_prefix("data:")
|
||||
.and_then(|s| s.split(';').next())
|
||||
.unwrap_or("image/jpeg");
|
||||
// 提取格式: image/jpeg -> jpeg
|
||||
let format =
|
||||
media_type.split('/').nth(1).unwrap_or("jpeg").to_string();
|
||||
return Some((format, data.to_string()));
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -60,6 +60,7 @@ struct ProcessedMessage {
|
||||
content: String,
|
||||
tool_uses: Option<Vec<CWToolUse>>,
|
||||
tool_results: Option<Vec<CWToolResult>>,
|
||||
images: Option<Vec<CWImage>>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -105,7 +106,7 @@ pub fn convert_anthropic_to_codewhisperer(
|
||||
content: combined,
|
||||
model_id: cw_model.clone(),
|
||||
origin: "AI_EDITOR".to_string(),
|
||||
images: None,
|
||||
images: messages[0].images.clone(), // 传递图片
|
||||
user_input_message_context: None,
|
||||
};
|
||||
|
||||
@@ -144,7 +145,7 @@ pub fn convert_anthropic_to_codewhisperer(
|
||||
content,
|
||||
model_id: cw_model.clone(),
|
||||
origin: "AI_EDITOR".to_string(),
|
||||
images: None,
|
||||
images: msg.images.clone(), // 传递图片
|
||||
user_input_message_context: None,
|
||||
};
|
||||
|
||||
@@ -181,24 +182,29 @@ pub fn convert_anthropic_to_codewhisperer(
|
||||
let history = fix_history_alternation(history, &cw_model);
|
||||
|
||||
// 构建当前消息
|
||||
let (current_content, current_tool_results) = if let Some(last_msg) = messages.last() {
|
||||
if last_msg.role == "assistant" {
|
||||
("Continue".to_string(), None)
|
||||
} else {
|
||||
let content = if last_msg.content.is_empty() {
|
||||
if last_msg.tool_results.is_some() {
|
||||
"Tool results provided.".to_string()
|
||||
} else {
|
||||
"Continue".to_string()
|
||||
}
|
||||
let (current_content, current_tool_results, current_images) =
|
||||
if let Some(last_msg) = messages.last() {
|
||||
if last_msg.role == "assistant" {
|
||||
("Continue".to_string(), None, None)
|
||||
} else {
|
||||
last_msg.content.clone()
|
||||
};
|
||||
(content, last_msg.tool_results.clone())
|
||||
}
|
||||
} else {
|
||||
("Continue".to_string(), None)
|
||||
};
|
||||
let content = if last_msg.content.is_empty() {
|
||||
if last_msg.tool_results.is_some() {
|
||||
"Tool results provided.".to_string()
|
||||
} else {
|
||||
"Continue".to_string()
|
||||
}
|
||||
} else {
|
||||
last_msg.content.clone()
|
||||
};
|
||||
(
|
||||
content,
|
||||
last_msg.tool_results.clone(),
|
||||
last_msg.images.clone(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
("Continue".to_string(), None, None)
|
||||
};
|
||||
|
||||
// 构建 tools
|
||||
let tools = convert_anthropic_tools(&request.tools);
|
||||
@@ -212,6 +218,14 @@ pub fn convert_anthropic_to_codewhisperer(
|
||||
None
|
||||
};
|
||||
|
||||
// 记录图片信息
|
||||
if let Some(ref imgs) = current_images {
|
||||
tracing::info!(
|
||||
"[KIRO_TRANSLATE] Current message contains {} image(s)",
|
||||
imgs.len()
|
||||
);
|
||||
}
|
||||
|
||||
CodeWhispererRequest {
|
||||
conversation_state: ConversationState {
|
||||
chat_trigger_type: "MANUAL".to_string(),
|
||||
@@ -221,7 +235,7 @@ pub fn convert_anthropic_to_codewhisperer(
|
||||
content: current_content,
|
||||
model_id: cw_model,
|
||||
origin: "AI_EDITOR".to_string(),
|
||||
images: None,
|
||||
images: current_images, // 传递当前消息的图片
|
||||
user_input_message_context,
|
||||
},
|
||||
},
|
||||
@@ -292,6 +306,7 @@ fn preprocess_anthropic_messages(messages: &[AnthropicMessage]) -> Vec<Processed
|
||||
} else {
|
||||
Some(pending_tool_results.clone())
|
||||
},
|
||||
images: msg.images, // 保留图片
|
||||
});
|
||||
pending_tool_results.clear();
|
||||
}
|
||||
@@ -306,6 +321,7 @@ fn preprocess_anthropic_messages(messages: &[AnthropicMessage]) -> Vec<Processed
|
||||
content: "Tool results provided.".to_string(),
|
||||
tool_uses: None,
|
||||
tool_results: Some(pending_tool_results.clone()),
|
||||
images: None,
|
||||
});
|
||||
pending_tool_results.clear();
|
||||
}
|
||||
@@ -323,6 +339,7 @@ fn preprocess_anthropic_messages(messages: &[AnthropicMessage]) -> Vec<Processed
|
||||
content: "Tool results provided.".to_string(),
|
||||
tool_uses: None,
|
||||
tool_results: Some(pending_tool_results),
|
||||
images: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -340,12 +357,14 @@ fn convert_anthropic_message(msg: &AnthropicMessage) -> Vec<ProcessedMessage> {
|
||||
content: s.clone(),
|
||||
tool_uses: None,
|
||||
tool_results: None,
|
||||
images: None,
|
||||
});
|
||||
}
|
||||
serde_json::Value::Array(parts) => {
|
||||
let mut text_parts: Vec<String> = Vec::new();
|
||||
let mut tool_uses: Vec<CWToolUse> = Vec::new();
|
||||
let mut tool_results: Vec<CWToolResult> = Vec::new();
|
||||
let mut images: Vec<CWImage> = Vec::new();
|
||||
|
||||
for part in parts {
|
||||
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
@@ -356,6 +375,39 @@ fn convert_anthropic_message(msg: &AnthropicMessage) -> Vec<ProcessedMessage> {
|
||||
text_parts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
// 处理 Anthropic 格式的图片
|
||||
// { "type": "image", "source": { "type": "base64", "media_type": "image/jpeg", "data": "..." } }
|
||||
if let Some(source) = part.get("source") {
|
||||
let source_type =
|
||||
source.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if source_type == "base64" {
|
||||
let media_type = source
|
||||
.get("media_type")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("image/jpeg");
|
||||
let data =
|
||||
source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
||||
|
||||
if !data.is_empty() {
|
||||
// 从 media_type 提取格式 (image/jpeg -> jpeg)
|
||||
let format =
|
||||
media_type.split('/').nth(1).unwrap_or("jpeg").to_string();
|
||||
|
||||
images.push(CWImage {
|
||||
format,
|
||||
source: CWImageSource {
|
||||
bytes: data.to_string(),
|
||||
},
|
||||
});
|
||||
tracing::debug!(
|
||||
"[KIRO_TRANSLATE] Converted image: media_type={}",
|
||||
media_type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let default_id = format!("toolu_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let id = part
|
||||
@@ -407,6 +459,7 @@ fn convert_anthropic_message(msg: &AnthropicMessage) -> Vec<ProcessedMessage> {
|
||||
Some(tool_uses)
|
||||
},
|
||||
tool_results: None,
|
||||
images: None, // assistant 消息不包含图片
|
||||
});
|
||||
}
|
||||
// 处理 user 消息
|
||||
@@ -418,16 +471,22 @@ fn convert_anthropic_message(msg: &AnthropicMessage) -> Vec<ProcessedMessage> {
|
||||
content: String::new(),
|
||||
tool_uses: None,
|
||||
tool_results: Some(tool_results),
|
||||
images: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加文本内容
|
||||
if !text_parts.is_empty() {
|
||||
// 添加文本内容和图片
|
||||
if !text_parts.is_empty() || !images.is_empty() {
|
||||
result.push(ProcessedMessage {
|
||||
role: "user".to_string(),
|
||||
content: text_parts.join(""),
|
||||
tool_uses: None,
|
||||
tool_results: None,
|
||||
images: if images.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(images)
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ struct ProcessedMessage {
|
||||
content: String,
|
||||
tool_calls: Option<Vec<CWToolUse>>,
|
||||
tool_results: Option<Vec<CWToolResult>>,
|
||||
images: Option<Vec<CWImage>>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -181,7 +182,7 @@ pub fn convert_openai_to_codewhisperer(
|
||||
content: combined,
|
||||
model_id: cw_model.clone(),
|
||||
origin: "AI_EDITOR".to_string(),
|
||||
images: None,
|
||||
images: messages[0].images.clone(), // 传递图片
|
||||
user_input_message_context: None,
|
||||
};
|
||||
|
||||
@@ -220,7 +221,7 @@ pub fn convert_openai_to_codewhisperer(
|
||||
content,
|
||||
model_id: cw_model.clone(),
|
||||
origin: "AI_EDITOR".to_string(),
|
||||
images: None,
|
||||
images: msg.images.clone(), // 传递图片
|
||||
user_input_message_context: None,
|
||||
};
|
||||
|
||||
@@ -257,24 +258,29 @@ pub fn convert_openai_to_codewhisperer(
|
||||
let history = fix_history_alternation(history, &cw_model);
|
||||
|
||||
// 构建当前消息
|
||||
let (current_content, current_tool_results) = if let Some(last_msg) = messages.last() {
|
||||
if last_msg.role == "assistant" {
|
||||
("Continue".to_string(), None)
|
||||
} else {
|
||||
let content = if last_msg.content.is_empty() {
|
||||
if last_msg.tool_results.is_some() {
|
||||
"Tool results provided.".to_string()
|
||||
} else {
|
||||
"Continue".to_string()
|
||||
}
|
||||
let (current_content, current_tool_results, current_images) =
|
||||
if let Some(last_msg) = messages.last() {
|
||||
if last_msg.role == "assistant" {
|
||||
("Continue".to_string(), None, None)
|
||||
} else {
|
||||
last_msg.content.clone()
|
||||
};
|
||||
(content, last_msg.tool_results.clone())
|
||||
}
|
||||
} else {
|
||||
("Continue".to_string(), None)
|
||||
};
|
||||
let content = if last_msg.content.is_empty() {
|
||||
if last_msg.tool_results.is_some() {
|
||||
"Tool results provided.".to_string()
|
||||
} else {
|
||||
"Continue".to_string()
|
||||
}
|
||||
} else {
|
||||
last_msg.content.clone()
|
||||
};
|
||||
(
|
||||
content,
|
||||
last_msg.tool_results.clone(),
|
||||
last_msg.images.clone(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
("Continue".to_string(), None, None)
|
||||
};
|
||||
|
||||
// 构建 tools
|
||||
let tools = convert_tools(&request.tools);
|
||||
@@ -288,6 +294,14 @@ pub fn convert_openai_to_codewhisperer(
|
||||
None
|
||||
};
|
||||
|
||||
// 记录图片信息
|
||||
if let Some(ref imgs) = current_images {
|
||||
tracing::info!(
|
||||
"[KIRO_TRANSLATE] OpenAI current message contains {} image(s)",
|
||||
imgs.len()
|
||||
);
|
||||
}
|
||||
|
||||
CodeWhispererRequest {
|
||||
conversation_state: ConversationState {
|
||||
chat_trigger_type: "MANUAL".to_string(),
|
||||
@@ -297,7 +311,7 @@ pub fn convert_openai_to_codewhisperer(
|
||||
content: current_content,
|
||||
model_id: cw_model,
|
||||
origin: "AI_EDITOR".to_string(),
|
||||
images: None,
|
||||
images: current_images, // 传递当前消息的图片
|
||||
user_input_message_context,
|
||||
},
|
||||
},
|
||||
@@ -336,6 +350,29 @@ fn preprocess_messages(messages: &[&ChatMessage]) -> Vec<ProcessedMessage> {
|
||||
let mut seen_ids = HashSet::new();
|
||||
tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone()));
|
||||
|
||||
// 提取图片
|
||||
let raw_images = msg.get_images();
|
||||
let images = if raw_images.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
raw_images
|
||||
.into_iter()
|
||||
.map(|(format, data)| CWImage {
|
||||
format,
|
||||
source: CWImageSource { bytes: data },
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
if images.is_some() {
|
||||
tracing::debug!(
|
||||
"[KIRO_TRANSLATE] OpenAI user message contains {} image(s)",
|
||||
images.as_ref().map(|v: &Vec<CWImage>| v.len()).unwrap_or(0)
|
||||
);
|
||||
}
|
||||
|
||||
result.push(ProcessedMessage {
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
@@ -345,6 +382,7 @@ fn preprocess_messages(messages: &[&ChatMessage]) -> Vec<ProcessedMessage> {
|
||||
} else {
|
||||
Some(tool_results)
|
||||
},
|
||||
images,
|
||||
});
|
||||
}
|
||||
"assistant" => {
|
||||
@@ -361,6 +399,7 @@ fn preprocess_messages(messages: &[&ChatMessage]) -> Vec<ProcessedMessage> {
|
||||
content: "Tool results provided.".to_string(),
|
||||
tool_calls: None,
|
||||
tool_results: Some(tool_results),
|
||||
images: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -382,6 +421,7 @@ fn preprocess_messages(messages: &[&ChatMessage]) -> Vec<ProcessedMessage> {
|
||||
content,
|
||||
tool_calls,
|
||||
tool_results: None,
|
||||
images: None, // assistant 消息不包含图片
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
@@ -399,6 +439,7 @@ fn preprocess_messages(messages: &[&ChatMessage]) -> Vec<ProcessedMessage> {
|
||||
content: "Tool results provided.".to_string(),
|
||||
tool_calls: None,
|
||||
tool_results: Some(tool_results),
|
||||
images: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,13 +48,14 @@
|
||||
- **VDOM 模式**: 支持在终端内嵌入 React 组件
|
||||
- **贴纸系统**: 支持在终端上显示可定位的贴纸标注
|
||||
- **分块布局**: 支持在主终端旁添加附加面板(对齐 Waveterm TileLayout)
|
||||
- **右侧小部件栏**: 快速添加 Terminal/Files/Web/Sysinfo 面板
|
||||
- **右侧小部件栏**: 快速添加 Terminal/Files/Web/Sysinfo/AI 面板
|
||||
- **Terminal AI**: 内置 AI 助手,支持终端上下文理解
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.ts` - 模块导出
|
||||
- `TerminalPage.tsx` - 终端页面组件(多标签页管理)
|
||||
- `TerminalWorkspace.tsx` - 终端工作区组件(分块布局 + 小部件栏)
|
||||
- `TerminalWorkspace.tsx` - 终端工作区组件(分块布局 + 小部件栏 + AI 面板)
|
||||
- `TerminalPanel.tsx` - 独立终端面板组件(用于分块布局)
|
||||
- `TerminalView.tsx` - 终端视图组件(使用 Jotai 原子状态)
|
||||
- `TerminalSearch.tsx` - 终端搜索组件
|
||||
@@ -69,6 +70,7 @@
|
||||
- `termwrap.ts` - 终端封装类(连接模式,WebGL/Unicode11 支持)
|
||||
- `fitaddon.ts` - 自定义 FitAddon
|
||||
- `terminal.css` - 终端样式(Tokyo Night 主题)
|
||||
- `ai/` - Terminal AI 模块(AI 助手面板)
|
||||
- `widgets/` - 小部件系统子目录
|
||||
|
||||
## widgets 子目录
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* - 右侧小部件栏
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import styled from "styled-components";
|
||||
import { TerminalPanel } from "./TerminalPanel";
|
||||
import {
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
WidgetProvider,
|
||||
WidgetType,
|
||||
} from "./widgets";
|
||||
import { TerminalAIPanel } from "./ai";
|
||||
import {
|
||||
ConnectionSelector,
|
||||
type ConnectionListEntry,
|
||||
@@ -38,7 +39,7 @@ import { Page } from "@/types/page";
|
||||
// ============================================================================
|
||||
|
||||
/** 附加面板类型 */
|
||||
export type SidePanelType = "terminal" | "files" | "web" | "sysinfo";
|
||||
export type SidePanelType = "terminal" | "files" | "web" | "sysinfo" | "ai";
|
||||
|
||||
/** 附加面板配置 */
|
||||
export interface SidePanel {
|
||||
@@ -184,6 +185,14 @@ const SysinfoIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AIIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
@@ -209,6 +218,12 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
{ id: "main-terminal", type: "terminal", title: "Terminal" },
|
||||
]);
|
||||
|
||||
// AI 面板状态
|
||||
const [showAIPanel, setShowAIPanel] = useState(false);
|
||||
|
||||
// 终端输出引用(用于 AI 上下文)
|
||||
const terminalOutputRef = useRef<string | null>(null);
|
||||
|
||||
// 连接编辑器模态窗口状态
|
||||
const [isConnectionsEditorOpen, setIsConnectionsEditorOpen] = useState(false);
|
||||
|
||||
@@ -224,6 +239,7 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
files: "Files",
|
||||
web: "Web",
|
||||
sysinfo: "Sysinfo",
|
||||
ai: "AI",
|
||||
};
|
||||
|
||||
// 如果有连接配置,使用连接标签作为标题
|
||||
@@ -281,6 +297,8 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
return <WebIcon />;
|
||||
case "sysinfo":
|
||||
return <SysinfoIcon />;
|
||||
case "ai":
|
||||
return <AIIcon />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -294,6 +312,11 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
[addPanel],
|
||||
);
|
||||
|
||||
// 获取终端输出(用于 AI 上下文)
|
||||
const getTerminalOutput = useCallback(() => {
|
||||
return terminalOutputRef.current;
|
||||
}, []);
|
||||
|
||||
// 渲染面板内容
|
||||
const renderPanelContent = (panel: SidePanel) => {
|
||||
switch (panel.type) {
|
||||
@@ -305,6 +328,8 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
return <WebView />;
|
||||
case "sysinfo":
|
||||
return <SysinfoView />;
|
||||
case "ai":
|
||||
return <TerminalAIPanel getTerminalOutput={getTerminalOutput} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -329,6 +354,10 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
case "sysinfo":
|
||||
addPanel("sysinfo");
|
||||
break;
|
||||
case "ai":
|
||||
// 切换 AI 面板显示
|
||||
setShowAIPanel((prev) => !prev);
|
||||
break;
|
||||
case "settings":
|
||||
onNavigate("settings");
|
||||
break;
|
||||
@@ -349,6 +378,15 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
return (
|
||||
<WidgetProvider>
|
||||
<WorkspaceOuterContainer>
|
||||
{/* AI 面板(左侧,参考 Waveterm) */}
|
||||
{showAIPanel && (
|
||||
<div
|
||||
style={{ width: 320, minWidth: 280, maxWidth: 400, flexShrink: 0 }}
|
||||
>
|
||||
<TerminalAIPanel getTerminalOutput={getTerminalOutput} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorkspaceContainer>
|
||||
{/* 所有面板统一渲染,都可以关闭和多开 */}
|
||||
{panels.map((panel) => (
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Terminal AI 模块
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
Terminal AI 是终端内置的 AI 助手功能,参考 Waveterm 的 AI 面板设计。
|
||||
|
||||
**核心特性:**
|
||||
- 复用 AI Agent 的模型选择器和 API
|
||||
- 支持终端上下文(Widget Context)
|
||||
- 流式响应显示
|
||||
- 工具调用支持
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.ts` | 模块导出 |
|
||||
| `types.ts` | 类型定义 |
|
||||
| `useTerminalAI.ts` | Terminal AI Hook |
|
||||
| `TerminalAIPanel.tsx` | AI 面板主组件 |
|
||||
| `TerminalAIInput.tsx` | 输入框组件 |
|
||||
| `TerminalAIMessages.tsx` | 消息列表组件 |
|
||||
| `TerminalAIModeSelector.tsx` | 模式/模型选择器 |
|
||||
| `TerminalAIWelcome.tsx` | 欢迎页面组件 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
```tsx
|
||||
import { TerminalAIPanel } from "@/components/terminal/ai";
|
||||
|
||||
function MyComponent() {
|
||||
const getTerminalOutput = () => {
|
||||
// 返回终端输出内容
|
||||
return "$ ls -la\ntotal 0\n...";
|
||||
};
|
||||
|
||||
return (
|
||||
<TerminalAIPanel getTerminalOutput={getTerminalOutput} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 功能说明
|
||||
|
||||
### Widget Context
|
||||
|
||||
开启后,AI 可以看到终端的最近输出(默认 50 行),用于:
|
||||
- 解释命令输出
|
||||
- 调试错误信息
|
||||
- 提供上下文相关的建议
|
||||
|
||||
### 模型选择
|
||||
|
||||
复用 AI Agent 的 Provider/Model 选择器,支持:
|
||||
- OAuth 凭证(Kiro、Gemini、Antigravity 等)
|
||||
- API Key 凭证(OpenAI、Claude 等)
|
||||
|
||||
### 快捷操作
|
||||
|
||||
欢迎页面提供快捷操作按钮:
|
||||
- 解释命令
|
||||
- 调试错误
|
||||
- 写脚本
|
||||
- 优化命令
|
||||
|
||||
## 依赖
|
||||
|
||||
- `@/lib/api/agent` - Agent API
|
||||
- `@/hooks/useProviderPool` - Provider 凭证
|
||||
- `@/hooks/useApiKeyProvider` - API Key 凭证
|
||||
- `@/hooks/useModelRegistry` - 模型注册表
|
||||
- `@/components/ui/*` - UI 组件
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @file TerminalAIInput.tsx
|
||||
* @description Terminal AI 输入框组件
|
||||
* @module components/terminal/ai/TerminalAIInput
|
||||
*
|
||||
* 参考 Waveterm 的 AIPanelInput 设计
|
||||
*/
|
||||
|
||||
import React, { useRef, useCallback, useEffect } from "react";
|
||||
import { Send, Square, Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TerminalAIInputProps {
|
||||
/** 输入值 */
|
||||
value: string;
|
||||
/** 输入变化回调 */
|
||||
onChange: (value: string) => void;
|
||||
/** 提交回调 */
|
||||
onSubmit: () => void;
|
||||
/** 停止回调 */
|
||||
onStop?: () => void;
|
||||
/** 是否正在发送 */
|
||||
isSending: boolean;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 占位符 */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const TerminalAIInput: React.FC<TerminalAIInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
isSending,
|
||||
disabled = false,
|
||||
placeholder = "Continue...",
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
/**
|
||||
* 自动调整高度
|
||||
*/
|
||||
const resizeTextarea = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
textarea.style.height = "auto";
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
const maxHeight = 7 * 24; // 7 行
|
||||
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeTextarea();
|
||||
}, [value, resizeTextarea]);
|
||||
|
||||
/**
|
||||
* 处理键盘事件
|
||||
*/
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isComposing = e.nativeEvent?.isComposing || e.keyCode === 229;
|
||||
if (e.key === "Enter" && !e.shiftKey && !isComposing) {
|
||||
e.preventDefault();
|
||||
if (!isSending && value.trim()) {
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理提交
|
||||
*/
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isSending && value.trim()) {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-zinc-700">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full text-white px-3 py-2 pr-16 focus:outline-none resize-none overflow-auto",
|
||||
"bg-zinc-800/50 text-sm",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{/* 附件按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute bottom-6 right-8 w-6 h-6 flex items-center justify-center",
|
||||
"text-zinc-400 hover:text-zinc-200 transition-colors",
|
||||
)}
|
||||
title="附加文件"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
|
||||
{/* 发送/停止按钮 */}
|
||||
{isSending ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className={cn(
|
||||
"absolute bottom-1.5 right-2 w-6 h-6 flex items-center justify-center",
|
||||
"text-green-500 hover:text-green-400 transition-colors",
|
||||
)}
|
||||
title="停止响应"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !value.trim()}
|
||||
className={cn(
|
||||
"absolute bottom-1.5 right-2 w-6 h-6 flex items-center justify-center",
|
||||
"transition-colors",
|
||||
disabled || !value.trim()
|
||||
? "text-zinc-500 cursor-not-allowed"
|
||||
: "text-blue-400 hover:text-blue-300",
|
||||
)}
|
||||
title="发送消息 (Enter)"
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @file TerminalAIMessages.tsx
|
||||
* @description Terminal AI 消息列表组件
|
||||
* @module components/terminal/ai/TerminalAIMessages
|
||||
*
|
||||
* 参考 Waveterm 的 AIPanelMessages 设计
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback, memo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AIMessage } from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// 子组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 思考中动画
|
||||
*/
|
||||
const AIThinking = memo(({ message = "思考中..." }: { message?: string }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-pulse flex items-center">
|
||||
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full" />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full mx-1" />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full" />
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">{message}</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
AIThinking.displayName = "AIThinking";
|
||||
|
||||
/**
|
||||
* 消息内容渲染
|
||||
*/
|
||||
const MessageContent = memo(
|
||||
({
|
||||
content,
|
||||
role,
|
||||
isStreaming,
|
||||
}: {
|
||||
content: string;
|
||||
role: string;
|
||||
isStreaming: boolean;
|
||||
}) => {
|
||||
if (role === "user") {
|
||||
return <div className="whitespace-pre-wrap break-words">{content}</div>;
|
||||
}
|
||||
|
||||
// 简单的 Markdown 渲染(代码块)
|
||||
const parts = content.split(/(```[\s\S]*?```)/g);
|
||||
|
||||
return (
|
||||
<div className="text-zinc-100 space-y-2">
|
||||
{parts.map((part, index) => {
|
||||
if (part.startsWith("```")) {
|
||||
// 代码块
|
||||
const match = part.match(/```(\w*)\n?([\s\S]*?)```/);
|
||||
if (match) {
|
||||
const [, lang, code] = match;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-zinc-900 rounded-md overflow-hidden"
|
||||
>
|
||||
{lang && (
|
||||
<div className="px-3 py-1 text-xs text-zinc-400 bg-zinc-800 border-b border-zinc-700">
|
||||
{lang}
|
||||
</div>
|
||||
)}
|
||||
<pre className="p-3 text-sm overflow-x-auto">
|
||||
<code>{code.trim()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 普通文本
|
||||
if (part.trim()) {
|
||||
return (
|
||||
<div key={index} className="whitespace-pre-wrap break-words">
|
||||
{part}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-zinc-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MessageContent.displayName = "MessageContent";
|
||||
|
||||
/**
|
||||
* 工具调用显示
|
||||
*/
|
||||
const ToolCallDisplay = memo(
|
||||
({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
result?: { success: boolean; output?: string; error?: string };
|
||||
};
|
||||
}) => {
|
||||
const statusIcon =
|
||||
toolCall.status === "completed"
|
||||
? "✓"
|
||||
: toolCall.status === "failed"
|
||||
? "✗"
|
||||
: "•";
|
||||
const statusColor =
|
||||
toolCall.status === "completed"
|
||||
? "text-green-500"
|
||||
: toolCall.status === "failed"
|
||||
? "text-red-500"
|
||||
: "text-zinc-400";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-2 rounded bg-zinc-800/60 border border-zinc-700 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-bold", statusColor)}>{statusIcon}</span>
|
||||
<span className="font-medium">{toolCall.name}</span>
|
||||
</div>
|
||||
{toolCall.result?.error && (
|
||||
<div className="text-red-300 pl-5">{toolCall.result.error}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ToolCallDisplay.displayName = "ToolCallDisplay";
|
||||
|
||||
/**
|
||||
* 单条消息
|
||||
*/
|
||||
const AIMessageItem = memo(
|
||||
({ message, isStreaming }: { message: AIMessage; isStreaming: boolean }) => {
|
||||
const isUser = message.role === "user";
|
||||
const hasContent =
|
||||
message.content ||
|
||||
(message.contentParts && message.contentParts.length > 0);
|
||||
|
||||
return (
|
||||
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg max-w-[85%]",
|
||||
isUser
|
||||
? "bg-zinc-700/60 text-white"
|
||||
: "bg-transparent min-w-[200px]",
|
||||
)}
|
||||
>
|
||||
{/* 思考中状态 */}
|
||||
{message.isThinking && !hasContent && (
|
||||
<AIThinking message={message.thinkingContent} />
|
||||
)}
|
||||
|
||||
{/* 交错内容渲染 */}
|
||||
{message.contentParts && message.contentParts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{message.contentParts.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
return (
|
||||
<MessageContent
|
||||
key={index}
|
||||
content={part.text}
|
||||
role={message.role}
|
||||
isStreaming={
|
||||
isStreaming &&
|
||||
index === message.contentParts!.length - 1
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (part.type === "tool_use") {
|
||||
return (
|
||||
<ToolCallDisplay key={index} toolCall={part.toolCall} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 回退到普通内容渲染
|
||||
hasContent && (
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
role={message.role}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 用户消息图片 */}
|
||||
{isUser && message.images && message.images.length > 0 && (
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
{message.images.map((img, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={`data:${img.mediaType};base64,${img.data}`}
|
||||
alt="附件"
|
||||
className="max-w-[100px] max-h-[100px] rounded object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AIMessageItem.displayName = "AIMessageItem";
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
interface TerminalAIMessagesProps {
|
||||
/** 消息列表 */
|
||||
messages: AIMessage[];
|
||||
/** 是否正在发送 */
|
||||
isSending: boolean;
|
||||
}
|
||||
|
||||
export const TerminalAIMessages: React.FC<TerminalAIMessagesProps> = ({
|
||||
messages,
|
||||
isSending,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
||||
|
||||
/**
|
||||
* 检查是否在底部
|
||||
*/
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return true;
|
||||
|
||||
const threshold = 50;
|
||||
const scrollBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
return scrollBottom <= threshold;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 滚动到底部
|
||||
*/
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
setShouldAutoScroll(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理滚动
|
||||
*/
|
||||
const handleScroll = useCallback(() => {
|
||||
setShouldAutoScroll(checkIfAtBottom());
|
||||
}, [checkIfAtBottom]);
|
||||
|
||||
// 监听滚动
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (shouldAutoScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, shouldAutoScroll, scrollToBottom]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{messages.map((message, index) => {
|
||||
const isLastMessage = index === messages.length - 1;
|
||||
const isStreaming =
|
||||
isSending && isLastMessage && message.role === "assistant";
|
||||
|
||||
return (
|
||||
<AIMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 空消息时的流式占位符 */}
|
||||
{isSending &&
|
||||
(messages.length === 0 ||
|
||||
messages[messages.length - 1].role !== "assistant") && (
|
||||
<AIMessageItem
|
||||
message={{
|
||||
id: "streaming-placeholder",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: new Date(),
|
||||
isThinking: true,
|
||||
thinkingContent: "思考中...",
|
||||
}}
|
||||
isStreaming={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* @file TerminalAIModeSelector.tsx
|
||||
* @description Terminal AI 模式选择器 - 复用 Agent 的模型选择逻辑
|
||||
* @module components/terminal/ai/TerminalAIModeSelector
|
||||
*
|
||||
* 参考 Waveterm 的 AIModeDropdown 设计,但复用 ProxyCast 的 Provider/Model 选择器
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useProviderPool } from "@/hooks/useProviderPool";
|
||||
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
|
||||
import { useModelRegistry } from "@/hooks/useModelRegistry";
|
||||
import { getProviderAliasConfig } from "@/lib/api/modelRegistry";
|
||||
import type { ProviderAliasConfig } from "@/lib/types/modelRegistry";
|
||||
|
||||
// ============================================================================
|
||||
// 常量
|
||||
// ============================================================================
|
||||
|
||||
/** Provider type 到 registry ID 的映射 */
|
||||
const getRegistryIdFromType = (providerType: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "google",
|
||||
kiro: "kiro",
|
||||
claude: "anthropic",
|
||||
claude_oauth: "anthropic",
|
||||
qwen: "alibaba",
|
||||
codex: "openai",
|
||||
antigravity: "antigravity",
|
||||
iflow: "openai",
|
||||
gemini_api_key: "google",
|
||||
};
|
||||
return typeMap[providerType.toLowerCase()] || providerType.toLowerCase();
|
||||
};
|
||||
|
||||
/** 需要使用别名配置的 Provider */
|
||||
const ALIAS_PROVIDERS = ["antigravity", "kiro"];
|
||||
|
||||
/** Provider 显示名称 */
|
||||
const getProviderLabel = (providerType: string): string => {
|
||||
const labelMap: Record<string, string> = {
|
||||
kiro: "Kiro",
|
||||
gemini: "Gemini OAuth",
|
||||
qwen: "通义千问",
|
||||
antigravity: "Antigravity",
|
||||
codex: "Codex",
|
||||
claude_oauth: "Claude OAuth",
|
||||
claude: "Claude",
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
gemini_api_key: "Gemini API Key",
|
||||
iflow: "iFlow",
|
||||
};
|
||||
return (
|
||||
labelMap[providerType.toLowerCase()] ||
|
||||
providerType.charAt(0).toUpperCase() + providerType.slice(1)
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
interface ConfiguredProvider {
|
||||
key: string;
|
||||
label: string;
|
||||
registryId: string;
|
||||
fallbackRegistryId?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface TerminalAIModeSelectorProps {
|
||||
/** 当前 Provider ID */
|
||||
providerId: string;
|
||||
/** Provider 变化回调 */
|
||||
onProviderChange: (id: string) => void;
|
||||
/** 当前模型 ID */
|
||||
modelId: string;
|
||||
/** 模型变化回调 */
|
||||
onModelChange: (id: string) => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件
|
||||
// ============================================================================
|
||||
|
||||
export const TerminalAIModeSelector: React.FC<TerminalAIModeSelectorProps> = ({
|
||||
providerId,
|
||||
onProviderChange,
|
||||
modelId,
|
||||
onModelChange,
|
||||
className,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aliasConfig, setAliasConfig] = useState<ProviderAliasConfig | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 获取凭证数据
|
||||
const { overview: oauthCredentials } = useProviderPool();
|
||||
const { providers: apiKeyProviders } = useApiKeyProvider();
|
||||
const { models: registryModels } = useModelRegistry({ autoLoad: true });
|
||||
|
||||
// 计算已配置的 Provider 列表
|
||||
const configuredProviders = useMemo(() => {
|
||||
const providerMap = new Map<string, ConfiguredProvider>();
|
||||
|
||||
// OAuth 凭证
|
||||
oauthCredentials.forEach((overview) => {
|
||||
if (overview.credentials.length > 0) {
|
||||
const key = overview.provider_type;
|
||||
if (!providerMap.has(key)) {
|
||||
providerMap.set(key, {
|
||||
key,
|
||||
label: getProviderLabel(key),
|
||||
registryId: getRegistryIdFromType(key),
|
||||
type: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// API Key Provider
|
||||
apiKeyProviders
|
||||
.filter((p) => p.api_key_count > 0 && p.enabled)
|
||||
.forEach((provider) => {
|
||||
let key = provider.id;
|
||||
let label = provider.name;
|
||||
|
||||
if (providerMap.has(key)) {
|
||||
key = `${provider.id}_api_key`;
|
||||
label = `${provider.name} API Key`;
|
||||
}
|
||||
|
||||
if (!providerMap.has(key)) {
|
||||
providerMap.set(key, {
|
||||
key,
|
||||
label,
|
||||
registryId: provider.id,
|
||||
fallbackRegistryId: getRegistryIdFromType(provider.type),
|
||||
type: provider.type,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(providerMap.values());
|
||||
}, [oauthCredentials, apiKeyProviders]);
|
||||
|
||||
// 当前选中的 Provider
|
||||
const selectedProvider = useMemo(() => {
|
||||
return configuredProviders.find((p) => p.key === providerId);
|
||||
}, [configuredProviders, providerId]);
|
||||
|
||||
// 加载别名配置
|
||||
useEffect(() => {
|
||||
if (selectedProvider && ALIAS_PROVIDERS.includes(selectedProvider.key)) {
|
||||
getProviderAliasConfig(selectedProvider.key)
|
||||
.then(setAliasConfig)
|
||||
.catch(() => setAliasConfig(null));
|
||||
} else {
|
||||
setAliasConfig(null);
|
||||
}
|
||||
}, [selectedProvider]);
|
||||
|
||||
// 当前 Provider 的模型列表
|
||||
const currentModels = useMemo(() => {
|
||||
if (!selectedProvider) return [];
|
||||
|
||||
// 别名 Provider 使用别名配置
|
||||
if (ALIAS_PROVIDERS.includes(selectedProvider.key) && aliasConfig) {
|
||||
return aliasConfig.models;
|
||||
}
|
||||
|
||||
// 从 model_registry 获取
|
||||
let models = registryModels
|
||||
.filter((m) => m.provider_id === selectedProvider.registryId)
|
||||
.map((m) => m.id);
|
||||
|
||||
if (models.length === 0 && selectedProvider.fallbackRegistryId) {
|
||||
models = registryModels
|
||||
.filter((m) => m.provider_id === selectedProvider.fallbackRegistryId)
|
||||
.map((m) => m.id);
|
||||
}
|
||||
|
||||
// 排序
|
||||
return models.sort((a, b) => {
|
||||
const aIsLatest = a.includes("-latest");
|
||||
const bIsLatest = b.includes("-latest");
|
||||
|
||||
if (aIsLatest && !bIsLatest) return -1;
|
||||
if (!aIsLatest && bIsLatest) return 1;
|
||||
|
||||
const dateRegex = /-(\d{8})$/;
|
||||
const aMatch = a.match(dateRegex);
|
||||
const bMatch = b.match(dateRegex);
|
||||
|
||||
if (aMatch && bMatch) {
|
||||
return bMatch[1].localeCompare(aMatch[1]);
|
||||
}
|
||||
|
||||
if (aMatch && !bMatch) return -1;
|
||||
if (!aMatch && bMatch) return 1;
|
||||
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
}, [selectedProvider, registryModels, aliasConfig]);
|
||||
|
||||
// 自动选择第一个模型
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedProvider &&
|
||||
ALIAS_PROVIDERS.includes(selectedProvider.key) &&
|
||||
!aliasConfig
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentModels.length > 0 && !currentModels.includes(modelId)) {
|
||||
onModelChange(currentModels[0]);
|
||||
}
|
||||
}, [currentModels, modelId, onModelChange, selectedProvider, aliasConfig]);
|
||||
|
||||
// 初始化 Provider
|
||||
useEffect(() => {
|
||||
if (configuredProviders.length > 0 && !selectedProvider) {
|
||||
onProviderChange(configuredProviders[0].key);
|
||||
}
|
||||
}, [configuredProviders, selectedProvider, onProviderChange]);
|
||||
|
||||
const displayLabel = selectedProvider?.label || providerId;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-1 rounded-md text-sm",
|
||||
"bg-zinc-800 hover:bg-zinc-700 text-zinc-200 transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{displayLabel}</span>
|
||||
<ChevronDown size={14} className="text-zinc-400" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[380px] p-0 bg-zinc-900/95 backdrop-blur-sm border-zinc-700"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex h-[280px]">
|
||||
{/* 左侧:Provider 列表 */}
|
||||
<div className="w-[130px] border-r border-zinc-700 bg-zinc-800/30 p-2 overflow-y-auto">
|
||||
<div className="text-xs font-semibold text-zinc-400 px-2 py-1 mb-1">
|
||||
Providers
|
||||
</div>
|
||||
{configuredProviders.length === 0 ? (
|
||||
<div className="text-xs text-zinc-500 p-2">
|
||||
暂无已配置的 Provider
|
||||
</div>
|
||||
) : (
|
||||
configuredProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.key}
|
||||
onClick={() => onProviderChange(provider.key)}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
|
||||
providerId === provider.key
|
||||
? "bg-blue-500/20 text-blue-400 font-medium"
|
||||
: "hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200",
|
||||
)}
|
||||
>
|
||||
{provider.label}
|
||||
{providerId === provider.key && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:模型列表 */}
|
||||
<div className="flex-1 p-2 flex flex-col overflow-hidden">
|
||||
<div className="text-xs font-semibold text-zinc-400 px-2 py-1 mb-1">
|
||||
Models
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-0.5 p-1">
|
||||
{currentModels.length === 0 ? (
|
||||
<div className="text-xs text-zinc-500 p-2">暂无可用模型</div>
|
||||
) : (
|
||||
currentModels.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
onModelChange(m);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
|
||||
modelId === m
|
||||
? "bg-zinc-700 text-zinc-100"
|
||||
: "hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{m}</span>
|
||||
{modelId === m && (
|
||||
<Check
|
||||
size={14}
|
||||
className="text-blue-400 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file TerminalAIPanel.tsx
|
||||
* @description Terminal AI 面板主组件
|
||||
* @module components/terminal/ai/TerminalAIPanel
|
||||
*
|
||||
* 参考 Waveterm 的 AIPanel 设计,实现终端 AI 助手面板
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Sparkles, MoreVertical, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { TerminalAIModeSelector } from "./TerminalAIModeSelector";
|
||||
import { TerminalAIMessages } from "./TerminalAIMessages";
|
||||
import { TerminalAIInput } from "./TerminalAIInput";
|
||||
import { TerminalAIWelcome } from "./TerminalAIWelcome";
|
||||
import { useTerminalAI } from "./useTerminalAI";
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
interface TerminalAIPanelProps {
|
||||
/** 获取终端输出的回调 */
|
||||
getTerminalOutput?: () => string | null;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件
|
||||
// ============================================================================
|
||||
|
||||
export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
|
||||
getTerminalOutput,
|
||||
className,
|
||||
}) => {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const {
|
||||
messages,
|
||||
isSending,
|
||||
config,
|
||||
providerId,
|
||||
setProviderId,
|
||||
modelId,
|
||||
setModelId,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
toggleWidgetContext,
|
||||
} = useTerminalAI(getTerminalOutput);
|
||||
|
||||
/**
|
||||
* 处理发送
|
||||
*/
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim()) return;
|
||||
const text = input;
|
||||
setInput("");
|
||||
await sendMessage(text);
|
||||
}, [input, sendMessage]);
|
||||
|
||||
/**
|
||||
* 处理快捷输入
|
||||
*/
|
||||
const handleQuickInput = useCallback((text: string) => {
|
||||
setInput(text);
|
||||
}, []);
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-zinc-900 border-r border-zinc-700",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-yellow-400" />
|
||||
<span className="font-medium text-zinc-200">Terminal AI</span>
|
||||
</div>
|
||||
|
||||
{/* 更多菜单 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors">
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-zinc-800 border-zinc-700"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={clearMessages}
|
||||
className="text-zinc-200 hover:bg-zinc-700 cursor-pointer"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
清空对话
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Widget Context 开关 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-700/50">
|
||||
<span className="text-sm text-zinc-400">Widget Context</span>
|
||||
<Switch
|
||||
checked={config.widgetContext}
|
||||
onCheckedChange={toggleWidgetContext}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模式选择器 */}
|
||||
<div className="px-3 py-2 border-b border-zinc-700/50">
|
||||
<TerminalAIModeSelector
|
||||
providerId={providerId}
|
||||
onProviderChange={setProviderId}
|
||||
modelId={modelId}
|
||||
onModelChange={setModelId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 消息区域 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
{hasMessages ? (
|
||||
<TerminalAIMessages messages={messages} isSending={isSending} />
|
||||
) : (
|
||||
<TerminalAIWelcome onQuickInput={handleQuickInput} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<TerminalAIInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSend}
|
||||
isSending={isSending}
|
||||
placeholder={
|
||||
hasMessages ? "Continue..." : "Ask Terminal AI anything..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file TerminalAIWelcome.tsx
|
||||
* @description Terminal AI 欢迎页面
|
||||
* @module components/terminal/ai/TerminalAIWelcome
|
||||
*
|
||||
* 参考 Waveterm 的 AIWelcomeMessage 设计
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { Sparkles, Terminal, FileText, Bug, Lightbulb } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
// ============================================================================
|
||||
|
||||
interface QuickAction {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface TerminalAIWelcomeProps {
|
||||
/** 快捷输入回调 */
|
||||
onQuickInput?: (text: string) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 常量
|
||||
// ============================================================================
|
||||
|
||||
const QUICK_ACTIONS: QuickAction[] = [
|
||||
{
|
||||
icon: <Terminal size={14} />,
|
||||
label: "解释命令",
|
||||
prompt: "请解释这个命令的作用:",
|
||||
},
|
||||
{
|
||||
icon: <Bug size={14} />,
|
||||
label: "调试错误",
|
||||
prompt: "帮我分析这个错误信息:",
|
||||
},
|
||||
{
|
||||
icon: <FileText size={14} />,
|
||||
label: "写脚本",
|
||||
prompt: "帮我写一个 shell 脚本:",
|
||||
},
|
||||
{
|
||||
icon: <Lightbulb size={14} />,
|
||||
label: "优化命令",
|
||||
prompt: "帮我优化这个命令:",
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// 组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 快捷操作按钮
|
||||
*/
|
||||
const QuickActionButton = memo(
|
||||
({ action, onClick }: { action: QuickAction; onClick: () => void }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg",
|
||||
"bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/50",
|
||||
"text-sm text-zinc-300 hover:text-zinc-100 transition-colors",
|
||||
"text-left",
|
||||
)}
|
||||
>
|
||||
<span className="text-zinc-400">{action.icon}</span>
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
QuickActionButton.displayName = "QuickActionButton";
|
||||
|
||||
/**
|
||||
* Terminal AI 欢迎页面
|
||||
*/
|
||||
export const TerminalAIWelcome: React.FC<TerminalAIWelcomeProps> = ({
|
||||
onQuickInput,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
{/* 图标和标题 */}
|
||||
<div className="mb-6">
|
||||
<Sparkles size={40} className="text-yellow-400 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold text-zinc-100">
|
||||
欢迎使用 Terminal AI
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-zinc-400 max-w-[280px] mb-6">
|
||||
我是你的终端助手,可以帮你解释命令、调试错误、编写脚本。 开启 Widget
|
||||
Context 后,我可以看到你的终端输出。
|
||||
</p>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<div className="w-full max-w-[280px]">
|
||||
<p className="text-xs text-zinc-500 mb-3">快捷操作</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{QUICK_ACTIONS.map((action, index) => (
|
||||
<QuickActionButton
|
||||
key={index}
|
||||
action={action}
|
||||
onClick={() => onQuickInput?.(action.prompt)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="mt-6 text-xs text-zinc-500">
|
||||
<p>按 Enter 发送消息,Shift+Enter 换行</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description Terminal AI 模块导出
|
||||
* @module components/terminal/ai
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export { useTerminalAI } from "./useTerminalAI";
|
||||
export { TerminalAIPanel } from "./TerminalAIPanel";
|
||||
export { TerminalAIInput } from "./TerminalAIInput";
|
||||
export { TerminalAIMessages } from "./TerminalAIMessages";
|
||||
export { TerminalAIModeSelector } from "./TerminalAIModeSelector";
|
||||
export { TerminalAIWelcome } from "./TerminalAIWelcome";
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description Terminal AI 类型定义
|
||||
* @module components/terminal/ai/types
|
||||
*
|
||||
* 定义 Terminal AI 面板相关的所有类型
|
||||
*/
|
||||
|
||||
import type { ToolCallState, TokenUsage } from "@/lib/api/agent";
|
||||
|
||||
/**
|
||||
* AI 消息图片
|
||||
*/
|
||||
export interface AIMessageImage {
|
||||
data: string;
|
||||
mediaType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容片段类型(用于交错显示)
|
||||
*/
|
||||
export type AIContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; toolCall: ToolCallState };
|
||||
|
||||
/**
|
||||
* AI 消息
|
||||
*/
|
||||
export interface AIMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
images?: AIMessageImage[];
|
||||
timestamp: Date;
|
||||
isThinking?: boolean;
|
||||
thinkingContent?: string;
|
||||
toolCalls?: ToolCallState[];
|
||||
usage?: TokenUsage;
|
||||
contentParts?: AIContentPart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal AI 配置
|
||||
*/
|
||||
export interface TerminalAIConfig {
|
||||
/** 是否启用终端上下文 */
|
||||
widgetContext: boolean;
|
||||
/** 上下文行数限制 */
|
||||
contextLines: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal AI 面板状态
|
||||
*/
|
||||
export interface TerminalAIPanelState {
|
||||
/** 是否展开 */
|
||||
isOpen: boolean;
|
||||
/** 面板宽度 */
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型选择结果
|
||||
*/
|
||||
export interface ModelSelection {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal AI Hook 返回值
|
||||
*/
|
||||
export interface UseTerminalAIReturn {
|
||||
// 状态
|
||||
messages: AIMessage[];
|
||||
isSending: boolean;
|
||||
config: TerminalAIConfig;
|
||||
|
||||
// 模型选择
|
||||
providerId: string;
|
||||
setProviderId: (id: string) => void;
|
||||
modelId: string;
|
||||
setModelId: (id: string) => void;
|
||||
|
||||
// 操作
|
||||
sendMessage: (content: string, images?: AIMessageImage[]) => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
toggleWidgetContext: () => void;
|
||||
setContextLines: (lines: number) => void;
|
||||
|
||||
// 终端上下文
|
||||
getTerminalContext: () => string | null;
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* @file useTerminalAI.ts
|
||||
* @description Terminal AI Hook - 管理 AI 聊天状态和操作
|
||||
* @module components/terminal/ai/useTerminalAI
|
||||
*
|
||||
* 复用 Agent 模块的 API,提供 Terminal 专用的 AI 聊天功能
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
startAgentProcess,
|
||||
getAgentProcessStatus,
|
||||
createAgentSession,
|
||||
sendAgentMessageStream,
|
||||
parseStreamEvent,
|
||||
type StreamEvent,
|
||||
} from "@/lib/api/agent";
|
||||
import type {
|
||||
AIMessage,
|
||||
AIMessageImage,
|
||||
AIContentPart,
|
||||
TerminalAIConfig,
|
||||
UseTerminalAIReturn,
|
||||
} from "./types";
|
||||
|
||||
// 存储键
|
||||
const STORAGE_KEYS = {
|
||||
PROVIDER: "terminal_ai_provider",
|
||||
MODEL: "terminal_ai_model",
|
||||
CONFIG: "terminal_ai_config",
|
||||
MESSAGES: "terminal_ai_messages",
|
||||
};
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG: TerminalAIConfig = {
|
||||
widgetContext: true,
|
||||
contextLines: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载持久化数据
|
||||
*/
|
||||
const loadPersisted = <T>(key: string, defaultValue: T): T => {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[useTerminalAI] 加载持久化数据失败:", e);
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存持久化数据
|
||||
*/
|
||||
const savePersisted = (key: string, value: unknown) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.error("[useTerminalAI] 保存持久化数据失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal AI Hook
|
||||
*
|
||||
* @param getTerminalOutput - 获取终端输出的回调函数
|
||||
*/
|
||||
export function useTerminalAI(
|
||||
getTerminalOutput?: () => string | null,
|
||||
): UseTerminalAIReturn {
|
||||
// 模型选择状态
|
||||
const [providerId, setProviderId] = useState(() =>
|
||||
loadPersisted(STORAGE_KEYS.PROVIDER, "claude"),
|
||||
);
|
||||
const [modelId, setModelId] = useState(() =>
|
||||
loadPersisted(STORAGE_KEYS.MODEL, "claude-sonnet-4-20250514"),
|
||||
);
|
||||
|
||||
// 配置状态
|
||||
const [config, setConfig] = useState<TerminalAIConfig>(() =>
|
||||
loadPersisted(STORAGE_KEYS.CONFIG, DEFAULT_CONFIG),
|
||||
);
|
||||
|
||||
// 消息状态
|
||||
const [messages, setMessages] = useState<AIMessage[]>([]);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
// 会话 ID
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
// 持久化
|
||||
useEffect(() => {
|
||||
savePersisted(STORAGE_KEYS.PROVIDER, providerId);
|
||||
}, [providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
savePersisted(STORAGE_KEYS.MODEL, modelId);
|
||||
}, [modelId]);
|
||||
|
||||
useEffect(() => {
|
||||
savePersisted(STORAGE_KEYS.CONFIG, config);
|
||||
}, [config]);
|
||||
|
||||
// 初始化 Agent 进程
|
||||
useEffect(() => {
|
||||
const initAgent = async () => {
|
||||
try {
|
||||
const status = await getAgentProcessStatus();
|
||||
if (!status.running) {
|
||||
await startAgentProcess();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[useTerminalAI] 初始化 Agent 失败:", e);
|
||||
}
|
||||
};
|
||||
initAgent();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 确保会话存在
|
||||
*/
|
||||
const ensureSession = useCallback(async (): Promise<string | null> => {
|
||||
if (sessionId) return sessionId;
|
||||
|
||||
try {
|
||||
// 构建系统提示词
|
||||
const systemPrompt = `你是一个终端助手,帮助用户解决命令行相关的问题。
|
||||
你可以:
|
||||
- 解释命令的用法和参数
|
||||
- 帮助调试错误信息
|
||||
- 建议更好的命令或脚本
|
||||
- 解答 shell 脚本相关问题
|
||||
|
||||
请用简洁清晰的语言回答,必要时提供代码示例。`;
|
||||
|
||||
const response = await createAgentSession(
|
||||
providerId,
|
||||
modelId,
|
||||
systemPrompt,
|
||||
);
|
||||
|
||||
setSessionId(response.session_id);
|
||||
return response.session_id;
|
||||
} catch (error) {
|
||||
console.error("[useTerminalAI] 创建会话失败:", error);
|
||||
toast.error("创建 AI 会话失败");
|
||||
return null;
|
||||
}
|
||||
}, [sessionId, providerId, modelId]);
|
||||
|
||||
/**
|
||||
* 获取终端上下文
|
||||
*/
|
||||
const getTerminalContext = useCallback((): string | null => {
|
||||
if (!config.widgetContext || !getTerminalOutput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output = getTerminalOutput();
|
||||
if (!output) return null;
|
||||
|
||||
// 限制行数
|
||||
const lines = output.split("\n");
|
||||
const limitedLines = lines.slice(-config.contextLines);
|
||||
return limitedLines.join("\n");
|
||||
}, [config.widgetContext, config.contextLines, getTerminalOutput]);
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, images?: AIMessageImage[]) => {
|
||||
if (!content.trim() && (!images || images.length === 0)) return;
|
||||
|
||||
// 创建用户消息
|
||||
const userMsg: AIMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
images,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// 创建助手消息占位符
|
||||
const assistantMsgId = crypto.randomUUID();
|
||||
const assistantMsg: AIMessage = {
|
||||
id: assistantMsgId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: new Date(),
|
||||
isThinking: true,
|
||||
thinkingContent: "思考中...",
|
||||
contentParts: [],
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||
setIsSending(true);
|
||||
|
||||
let accumulatedContent = "";
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
/**
|
||||
* 追加文本到 contentParts
|
||||
*/
|
||||
const appendTextToParts = (
|
||||
parts: AIContentPart[],
|
||||
text: string,
|
||||
): AIContentPart[] => {
|
||||
const newParts = [...parts];
|
||||
const lastPart = newParts[newParts.length - 1];
|
||||
|
||||
if (lastPart && lastPart.type === "text") {
|
||||
newParts[newParts.length - 1] = {
|
||||
type: "text",
|
||||
text: lastPart.text + text,
|
||||
};
|
||||
} else {
|
||||
newParts.push({ type: "text", text });
|
||||
}
|
||||
return newParts;
|
||||
};
|
||||
|
||||
try {
|
||||
const activeSessionId = await ensureSession();
|
||||
if (!activeSessionId) {
|
||||
throw new Error("无法创建会话");
|
||||
}
|
||||
|
||||
const eventName = `terminal_ai_stream_${assistantMsgId}`;
|
||||
|
||||
// 设置事件监听
|
||||
unlisten = await listen<StreamEvent>(eventName, (event) => {
|
||||
const data = parseStreamEvent(event.payload);
|
||||
if (!data) return;
|
||||
|
||||
switch (data.type) {
|
||||
case "text_delta":
|
||||
accumulatedContent += data.text;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
content: accumulatedContent,
|
||||
thinkingContent: undefined,
|
||||
contentParts: appendTextToParts(
|
||||
msg.contentParts || [],
|
||||
data.text,
|
||||
),
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case "final_done":
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
isThinking: false,
|
||||
content: accumulatedContent || "(无响应)",
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
setIsSending(false);
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
toast.error(`AI 响应错误: ${data.message}`);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
isThinking: false,
|
||||
content: accumulatedContent || `错误: ${data.message}`,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
setIsSending(false);
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_start": {
|
||||
const newToolCall = {
|
||||
id: data.tool_id,
|
||||
name: data.tool_name,
|
||||
arguments: data.arguments,
|
||||
status: "running" as const,
|
||||
startTime: new Date(),
|
||||
};
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), newToolCall],
|
||||
contentParts: [
|
||||
...(msg.contentParts || []),
|
||||
{ type: "tool_use" as const, toolCall: newToolCall },
|
||||
],
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_end": {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
|
||||
const updatedToolCalls = (msg.toolCalls || []).map((tc) =>
|
||||
tc.id === data.tool_id
|
||||
? {
|
||||
...tc,
|
||||
status: data.result.success
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: data.result,
|
||||
endTime: new Date(),
|
||||
}
|
||||
: tc,
|
||||
);
|
||||
|
||||
const updatedContentParts = (msg.contentParts || []).map(
|
||||
(part) => {
|
||||
if (
|
||||
part.type === "tool_use" &&
|
||||
part.toolCall.id === data.tool_id
|
||||
) {
|
||||
return {
|
||||
...part,
|
||||
toolCall: {
|
||||
...part.toolCall,
|
||||
status: data.result.success
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: data.result,
|
||||
endTime: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return part;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
toolCalls: updatedToolCalls,
|
||||
contentParts: updatedContentParts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 构建消息内容(包含终端上下文)
|
||||
let messageContent = content;
|
||||
const terminalContext = getTerminalContext();
|
||||
if (terminalContext) {
|
||||
messageContent = `[终端上下文]\n\`\`\`\n${terminalContext}\n\`\`\`\n\n[用户问题]\n${content}`;
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const imagesToSend = images?.map((img) => ({
|
||||
data: img.data,
|
||||
media_type: img.mediaType,
|
||||
}));
|
||||
|
||||
await sendAgentMessageStream(
|
||||
messageContent,
|
||||
eventName,
|
||||
activeSessionId,
|
||||
modelId,
|
||||
imagesToSend,
|
||||
providerId,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[useTerminalAI] 发送消息失败:", error);
|
||||
toast.error(`发送失败: ${error}`);
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId));
|
||||
setIsSending(false);
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSession, getTerminalContext, modelId, providerId],
|
||||
);
|
||||
|
||||
/**
|
||||
* 清空消息
|
||||
*/
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setSessionId(null);
|
||||
toast.success("对话已清空");
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 切换终端上下文
|
||||
*/
|
||||
const toggleWidgetContext = useCallback(() => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgetContext: !prev.widgetContext,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 设置上下文行数
|
||||
*/
|
||||
const setContextLines = useCallback((lines: number) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
contextLines: lines,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isSending,
|
||||
config,
|
||||
providerId,
|
||||
setProviderId,
|
||||
modelId,
|
||||
setModelId,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
toggleWidgetContext,
|
||||
setContextLines,
|
||||
getTerminalContext,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,22 @@ export { TerminalPanel } from "./TerminalPanel";
|
||||
// 小部件系统(供外部使用,如独立页面)
|
||||
export { SysinfoView, FileBrowserView, WebView } from "./widgets";
|
||||
|
||||
// Terminal AI 组件
|
||||
export {
|
||||
TerminalAIPanel,
|
||||
TerminalAIInput,
|
||||
TerminalAIMessages,
|
||||
TerminalAIModeSelector,
|
||||
useTerminalAI,
|
||||
} from "./ai";
|
||||
export type {
|
||||
AIMessage,
|
||||
AIMessageImage,
|
||||
AIContentPart,
|
||||
TerminalAIConfig,
|
||||
UseTerminalAIReturn,
|
||||
} from "./ai";
|
||||
|
||||
// VDOM 组件
|
||||
// _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||
export { VDomModeSwitch, VDomModeToggle } from "./VDomModeSwitch";
|
||||
|
||||
@@ -20,21 +20,37 @@ interface WidgetProviderProps {
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载小部件配置
|
||||
*
|
||||
* 合并策略:
|
||||
* 1. 以 DEFAULT_WIDGETS 为基准,确保新增的 widget 会被包含
|
||||
* 2. 保留用户对已有 widget 的自定义配置(如 hidden 状态)
|
||||
* 3. 移除 DEFAULT_WIDGETS 中不存在的旧 widget
|
||||
*/
|
||||
function loadWidgetConfig(): WidgetConfig[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.WIDGET_CONFIG);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// 合并默认配置和存储的配置
|
||||
return DEFAULT_WIDGETS.map((defaultWidget) => {
|
||||
const parsed = JSON.parse(stored) as WidgetConfig[];
|
||||
// 以 DEFAULT_WIDGETS 为基准合并,确保新 widget 会被添加
|
||||
const merged = DEFAULT_WIDGETS.map((defaultWidget) => {
|
||||
const storedWidget = parsed.find(
|
||||
(w: WidgetConfig) => w.id === defaultWidget.id,
|
||||
);
|
||||
// 只保留用户可自定义的属性(如 hidden),其他用默认值
|
||||
return storedWidget
|
||||
? { ...defaultWidget, ...storedWidget }
|
||||
? { ...defaultWidget, hidden: storedWidget.hidden }
|
||||
: defaultWidget;
|
||||
});
|
||||
|
||||
// 检查是否有新增的 widget,如果有则更新 localStorage
|
||||
const storedIds = new Set(parsed.map((w: WidgetConfig) => w.id));
|
||||
const hasNewWidgets = DEFAULT_WIDGETS.some((w) => !storedIds.has(w.id));
|
||||
if (hasNewWidgets) {
|
||||
// 异步更新 localStorage,不阻塞加载
|
||||
setTimeout(() => saveWidgetConfig(merged), 0);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("加载小部件配置失败:", e);
|
||||
|
||||
@@ -17,6 +17,15 @@ import {
|
||||
* 默认小部件配置
|
||||
*/
|
||||
export const DEFAULT_WIDGETS: WidgetConfig[] = [
|
||||
{
|
||||
id: "ai",
|
||||
label: "AI",
|
||||
icon: "Sparkles",
|
||||
color: "var(--warning-color, #F59E0B)",
|
||||
description: "Terminal AI 助手",
|
||||
displayOrder: 0,
|
||||
type: "ai",
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
label: "终端",
|
||||
|
||||
@@ -17,6 +17,7 @@ export type WidgetType =
|
||||
| "files"
|
||||
| "web"
|
||||
| "sysinfo"
|
||||
| "ai"
|
||||
| "settings"
|
||||
| "tips"
|
||||
| "help"
|
||||
|
||||
Reference in New Issue
Block a user