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:
coso
2026-01-08 19:13:09 +08:00
co-authored by Claude Opus 4.5
parent d8a9bf5a0d
commit f0b8c74a98
20 changed files with 1986 additions and 50 deletions
+35
View File
@@ -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)
},
});
}
}
+61 -20
View File
@@ -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,
});
}
+4 -2
View File
@@ -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 子目录
+40 -2
View File
@@ -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) => (
+78
View File
@@ -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>
);
};
+13
View File
@@ -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";
+94
View File
@@ -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;
}
+454
View File
@@ -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,
};
}
+16
View File
@@ -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: "终端",
+1
View File
@@ -17,6 +17,7 @@ export type WidgetType =
| "files"
| "web"
| "sysinfo"
| "ai"
| "settings"
| "tips"
| "help"
View File