Files

1 line
128 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "achuan-2/slidesci", "files": {"SlideSCI/components/AISidebarControl.cs": {"fdab6a504df7eee331d5c0676e6b1f60091db177": {"t": 1785746184.6312273, "n": 59906, "h": "9e04dab5d8dcad98", "c": "using System;\nusing System.Collections.Generic;\nusing System.Drawing;\nusing System.Drawing.Drawing2D;\nusing System.IO;\nusing System.Net.Http;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing System.Windows.Forms;\nusing Newtonsoft.Json;\nusing PowerPoint = Microsoft.Office.Interop.PowerPoint;\n\nnamespace SlideSCI\n{\n public class AISidebarControl : UserControl\n {\n private FlowLayoutPanel chatFlowLayout;\n \n private Panel inputPanel;\n private TextBox txtInput;\n private Button btnSend;\n private Button btnClear;\n private Button btnSettings;\n private ToolTip toolTip;\n\n private string configPath;\n private AIConfig currentConfig;\n private List<ChatMessage> conversationHistory = new List<ChatMessage>();\n private static readonly HttpClient httpClient = new HttpClient();\n private bool isGenerating = false;\n private CancellationTokenSource cts;\n\n public PowerPoint.DocumentWindow AssociatedWindow { get; set; }\n\n public AISidebarControl()\n {\n // Enable TLS 1.2 and TLS 1.3 for secure API calls\n try\n {\n System.Net.ServicePointManager.SecurityProtocol |= \n System.Net.SecurityProtocolType.Tls12 | \n System.Net.SecurityProtocolType.Tls11 |\n (System.Net.SecurityProtocolType)12288; // TLS 1.3\n }\n catch { }\n\n InitializeConfigPath();\n LoadConfig();\n InitializeComponent();\n InitializeSystemMessage();\n }\n\n private void InitializeConfigPath()\n {\n string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);\n string slideSciDir = Path.Combine(appData, \"SlideSCI\");\n if (!Directory.Exists(slideSciDir))\n {\n Directory.CreateDirectory(slideSciDir);\n }\n configPath = Path.Combine(slideSciDir, \"ai_config.json\");\n }\n\n private void LoadConfig()\n {\n if (File.Exists(configPath))\n {\n try\n {\n string json = File.ReadAllText(configPath);\n currentConfig = JsonConvert.DeserializeObject<AIConfig>(json);\n\n // Migration: If no presets exist but we have old single-config fields, create a default preset\n if (currentConfig != null && (currentConfig.Presets == null || currentConfig.Presets.Count == 0))\n {\n var dict = JsonConvert.DeserializeObject<Dictionary<string, object>>(json);\n string oldUrl = dict.ContainsKey(\"ApiUrl\") ? dict[\"ApiUrl\"]?.ToString() : null;\n string oldKey = dict.ContainsKey(\"ApiKey\") ? dict[\"ApiKey\"]?.ToString() : null;\n string oldModel = dict.ContainsKey(\"Model\") ? dict[\"Model\"]?.ToString() : null;\n string oldPrompt = dict.ContainsKey(\"SystemPrompt\") ? dict[\"SystemPrompt\"]?.ToString() : null;\n\n currentConfig.Presets = new List<AIPreset>();\n if (!string.IsNullOrEmpty(oldUrl) || !string.IsNullOrEmpty(oldKey))\n {\n currentConfig.Presets.Add(new AIPreset\n {\n Name = \"Default\",\n ApiUrl = oldUrl ?? \"https://api.openai.com/v1\",\n ApiKey = oldKey ?? \"\",\n Model = oldModel ?? \"gpt-4o\",\n SystemPrompt = oldPrompt ?? GetDefaultSystemPrompt()\n });\n currentConfig.CurrentPresetName = \"Default\";\n }\n }\n }\n catch\n {\n currentConfig = new AIConfig();\n }\n }\n else\n {\n currentConfig = new AIConfig();\n }\n\n if (currentConfig == null) currentConfig = new AIConfig();\n\n // Ensure we have some default presets if list is still empty\n if (currentConfig.Presets == null || currentConfig.Presets.Count == 0)\n {\n currentConfig.Presets = new List<AIPreset>\n {\n new AIPreset { Name = \"Kimi\", ApiUrl = \"https://api.moonshot.cn/v1\", Model = \"kimi-latest\", SystemPrompt = GetDefaultSystemPrompt() },\n new AIPreset { Name = \"DeepSeek\", ApiUrl = \"https://api.deepseek.com/v1\", Model = \"deepseek-chat\", SystemPrompt = GetDefaultSystemPrompt() },\n new AIPreset { Name = \"OpenAI\", ApiUrl = \"https://api.openai.com/v1\", Model = \"gpt-4o\", SystemPrompt = GetDefaultSystemPrompt() }\n };\n currentConfig.CurrentPresetName = \"Kimi\";\n SaveConfig();\n }\n\n if (string.IsNullOrWhiteSpace(currentConfig.SystemPrompt) ||\n currentConfig.SystemPrompt.Contains(\"Office.MsoShapeType.msoShapeRectangle\") ||\n currentConfig.SystemPrompt.Contains(\"msoShapeRoundRectangle\"))\n {\n currentConfig.SystemPrompt = GetDefaultSystemPrompt();\n SaveConfig();\n }\n }\n\n private void SaveConfig()\n {\n try\n {\n string json = JsonConvert.SerializeObject(currentConfig, Formatting.Indented);\n File.WriteAllText(configPath, json);\n }\n catch (Exception ex)\n {\n MessageBox.Show($\"保存设置失败: {ex.Message}\", \"错误\", MessageBoxButtons.OK, MessageBoxIcon.Error);\n }\n }\n\n private void InitializeComponent()\n {\n this.Dock = DockStyle.Fill;\n this.BackColor = Color.FromArgb(245, 246, 247);\n this.Font = new Font(\"Microsoft YaHei\", 9F, FontStyle.Regular, GraphicsUnit.Point);\n\n toolTip = new ToolTip();\n\n // 1. Input Panel (Docked at Bottom)\n inputPanel = new Panel\n {\n Dock = DockStyle.Bottom,\n Height = 105,\n BackColor = Color.White,\n Padding = new Padding(10, 8, 10, 8)\n };\n inputPanel.Paint += (s, e) =>\n {\n using (Pen pen = new Pen(Color.FromArgb(225, 225, 225), 1))\n {\n e.Graphics.DrawLine(pen, 0, 0, inputPanel.Width, 0);\n }\n };\n\n // Custom border panel for textbox\n Panel txtWrapper = new Panel\n {\n Location = new Point(10, 8),\n Width = this.Width - 20,\n Height = 52,\n BackColor = Color.White,\n Padding = new Padding(4)\n };\n txtWrapper.Paint += (s, e) =>\n {\n Color borderColor = txtInput.Focused ? Color.FromArgb(0, 120, 212) : Color.FromArgb(220, 220, 220);\n using (Pen pen = new Pen(borderColor, 1))\n {\n e.Graphics.DrawRectangle(pen, 0, 0, txtWrapper.Width - 1, txtWrapper.Height - 1);\n }\n };\n\n txtInput = new TextBox\n {\n Multiline = true,\n BorderStyle = BorderStyle.None,\n Dock = DockStyle.Fill,\n Font = new Font(\"Microsoft YaHei\", 9F),\n ScrollBars = ScrollBars.Vertical\n };\n txtInput.GotFocus += (s, e) => txtWrapper.Invalidate();\n txtInput.LostFocus += (s, e) => txtWrapper.Invalidate();\n txtInput.KeyDown += TxtInput_KeyDown;\n txtWrapper.Controls.Add(txtInput);\n inputPanel.Controls.Add(txtWrapper);\n\n // Icon-only Clear button\n btnClear = new Button\n {\n Image = CreateIcon(\"trash\", 16, Color.FromArgb(100, 100, 100)),\n FlatStyle = FlatStyle.Flat,\n BackColor = Color.Transparent,\n Location = new Point(10, 68),\n Width = 28,\n Height = 26,\n Cursor = Cursors.Hand\n };\n btnClear.FlatAppearance.BorderSize = 0;\n btnClear.FlatAppearance.MouseOverBackColor = Color.FromArgb(240, 240, 240);\n btnClear.Click += (s, e) => ClearChat();\n toolTip.SetToolTip(btnClear, \"清空对话\");\n inputPanel.Controls.Add(btnClear);\n\n // Icon-only Settings button\n btnSettings = new Button\n {\n Image = CreateIcon(\"settings\", 16, Color.FromArgb(100, 100, 100)),\n FlatStyle = FlatStyle.Flat,\n BackColor = Color.Transparent,\n Location = new Point(44, 68),\n Width = 28,\n Height = 26,\n Cursor = Cursors.Hand\n };\n btnSettings.FlatAppearance.BorderSize = 0;\n btnSettings.FlatAppearance.MouseOverBackColor = Color.FromArgb(240, 240, 240);\n btnSettings.Click += BtnSettings_Click;\n toolTip.SetToolTip(btnSettings, \"AI 设置\");\n inputPanel.Controls.Add(btnSettings);\n\n btnSend = new Button\n {\n Text = \"发送\",\n FlatStyle = FlatStyle.Flat,\n BackColor = Color.FromArgb(0, 120, 212),\n ForeColor = Color.White,\n Font = new Font(\"Microsoft YaHei\", 9F, FontStyle.Bold),\n Location = new Point(this.Width - 75, 68),\n Width = 65,\n Height = 26,\n Cursor = Cursors.Hand\n };\n btnSend.FlatAppearance.BorderSize = 0;\n btnSend.Click += async (s, e) => await HandleSendAsync();\n inputPanel.Controls.Add(btnSend);\n\n this.Controls.Add(inputPanel);\n\n // 2. Chat Flow Layout Panel (Docked Fill)\n chatFlowLayout = new FlowLayoutPanel\n {\n Dock = DockStyle.Fill,\n AutoScroll = true,\n FlowDirection = FlowDirection.LeftToRight,\n WrapContents = true,\n Padding = new Padding(8),\n BackColor = Color.FromArgb(245, 246, 247)\n };\n typeof(FlowLayoutPanel).InvokeMember(\"DoubleBuffered\",\n System.Reflection.BindingFlags.SetProperty | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,\n null, chatFlowLayout, new object[] { true });\n\n this.Controls.Add(chatFlowLayout);\n\n // Ensure Z-order prevents bottom layout overlapping\n chatFlowLayout.BringToFront();\n\n this.Resize += AISidebarControl_Resize;\n chatFlowLayout.Resize += (s, e) => UpdateBubbleWidths();\n }\n\n private void AISidebarControl_Resize(object sender, EventArgs e)\n {\n if (inputPanel != null)\n {\n foreach (Control ctrl in inputPanel.Controls)\n {\n if (ctrl is Panel p) // txtWrapper\n {\n p.Width = inputPanel.Width - 20;\n }\n else if (ctrl == btnSend)\n {\n btnSend.Left = inputPanel.Width - 75;\n }\n }\n }\n UpdateBubbleWidths();\n }\n\n private void BtnSettings_Click(object sender, EventArgs e)\n {\n using (var settingsForm = new AISettingsForm(currentConfig))\n {\n if (settingsForm.ShowDialog(this) == DialogResult.OK)\n {\n settingsForm.UpdateConfig(currentConfig);\n SaveConfig();\n\n // Update the active system message in the conversation history\n if (conversationHistory.Count > 0 && conversationHistory[0].Role == \"system\")\n {\n conversationHistory[0].Content = currentConfig.SystemPrompt;\n }\n }\n }\n }\n\n private void InitializeSystemMessage()\n {\n string systemPrompt = currentConfig.SystemPrompt;\n if (string.IsNullOrWhiteSpace(systemPrompt))\n {\n systemPrompt = GetDefaultSystemPrompt();\n currentConfig.SystemPrompt = systemPrompt;\n }\n\n conversationHistory.Add(new ChatMessage { Role = \"system\", Content = systemPrompt });\n AppendBubble(\"你好!我是你的 SlideSCI AI 助手。我可以执行以下命令:\\n1. 把选中的文字或文本框颜色改为红色、蓝色等。\\n2. 添加各种形状(矩形、圆形等)。\\n3. 新增一页空白幻灯片。\\n4. 将幻灯片中的指定文字进行查找替换。\\n\\n请在下方输入你的指令!\", isUser: false, isSystem: false);\n }\n\n public static string GetDefaultSystemPrompt()\n {\n return @\"你是一个PowerPoint AI助手,你可以通过编写和执行C#代码来帮用户操作当前幻灯片。\n你有调用 `execute_csharp_code` 工具的能力。\n当你需要对幻灯片进行操作时(如修改文字颜色、添加形状、新建页面、替换文字等),你应该写C#代码,然后通过调用 `execute_csharp_code` 来执行。\n\n编写C#代码时请遵守以下规则:\n1. 代码会在一个包含 `PowerPoint.Application Application` 参数的方法体内执行:`public static void Run(PowerPoint.Application Application)`。\n2. 已经默认导入了以下命名空间,请勿再在代码中写 using 声明:\n - System\n - System.Collections.Generic\n - System.Linq\n - System.Drawing\n - System.Windows.Forms\n - Microsoft.Office.Interop.PowerPoint (别名为 PowerPoint)\n - Microsoft.Office.Core (别名为 Office)\n3. 安全获取当前幻灯片对象 (Slide) 的方法:\n PowerPoint.Slide slide = null;\n try { slide = Application.ActiveWindow.View.Slide as PowerPoint.Slide; } catch {}\n if (slide == null) {\n try { slide = Application.ActivePresentation.Slides[Application.ActiveWindow.Selection.SlideRange[1].SlideIndex]; } catch {}\n }\n if (slide == null) {\n throw new Exception(\"\"没有找到活动的幻灯片,请确保PPT处于正常视图。\"\");\n }\n4. 新增幻灯片页面请直接对 Application.ActivePresentation.Slides 进行 Add 或 Add2 操作,例如:\n var pres = Application.ActivePresentation;\n pres.Slides.Add(pres.Slides.Count + 1, PowerPoint.PpSlideLayout.ppLayoutBlank);\n5. 操作文字颜色时,请使用 `System.Drawing.ColorTranslator.ToOle(Color.Red)` 将颜色转换为 OLE 颜色。例如:\n shape.TextFrame.TextRange.Font.Color.RGB = ColorTranslator.ToOle(Color.Red);\n6. 插入形状时,第一个参数必须是 `Office.MsoAutoShapeType` 枚举类型(注意:千万不可写错成 `Office.MsoShapeType`,否则会报编译错误)。常用的形状枚举如下:\n - 矩形:`Office.MsoAutoShapeType.msoShapeRectangle`\n - 圆角矩形(注意是 Rounded,不是 Round):`Office.MsoAutoShapeType.msoShapeRoundedRectangle`\n - 椭圆/圆形:`Office.MsoAutoShapeType.msoShapeOval`\n 示例:`slide.Shapes.AddShape(Office.MsoAutoShapeType.msoShapeRectangle, left, top, width, height)`。位置与尺寸参数皆为 float 类型。\n7. 修改文字内容时,如果需要查找并替换,可以遍历幻灯片中的 Shape 并检查 `shape.HasTextFrame == Office.MsoTriState.msoTrue && shape.TextFrame.HasText == Office.MsoTriState.msoTrue`。\n8. 请尽量编写健壮的代码,用 try-catch 包裹可能出错的地方,遇到不支持的操作时抛出异常。\n\n【核心PPT设计审美与排版规范(PPT Aesthetics & Layout Design Rules)】\n为了确保AI添加的PPT样式美观、专业且符合现代高端设计规范,请在编写代码生成/修改PPT内容时,严格遵守以下排版与审美原则:\n\n1. 配色系统与 60-30-10 黄金比例:\n - 禁止在没有用户明确要求的情况下使用刺眼的高饱和度纯原色(例如纯红 Color.Red、纯蓝 Color.Blue、纯绿 Color.Green、纯黄 Color.Yellow)。这会让 PPT 显得极不专业。\n - 使用成熟、高对比度且优雅的经典主题配色(使用 Color.FromArgb 定义颜色,然后使用 ColorTranslator.ToOle 转换):\n * 极简科技蓝主题:\n - 主背景/主色:浅灰白 Color.FromArgb(248, 249, 250) 或 极深蓝 Color.FromArgb(10, 34, 64)\n - 辅助色/卡片背景:浅天蓝 Color.FromArgb(232, 240, 254) 或 经典蓝 Color.FromArgb(26, 115, 232)\n - 点缀强调色(10%):温暖橙/金色 Color.FromArgb(245, 166, 35) 或 珊瑚红 Color.FromArgb(234, 67, 53)\n * 现代商务炭黑主题:\n - 主色:炭黑色 Color.FromArgb(43, 43, 43)\n - 辅助色:浅灰色 Color.FromArgb(240, 240, 240)\n - 点缀色:青/湖蓝色 Color.FromArgb(0, 168, 181)\n * 暖调人文/学术主题:\n - 主色:深林绿 Color.FromArgb(27, 67, 50)\n - 辅助色:柔和米色 Color.FromArgb(245, 242, 235)\n - 点缀色:赤陶红/土黄 Color.FromArgb(190, 130, 60)\n - 遵守 60-30-10 比例:背景占 60% 视觉面积,文字/卡片容器等次要元素占 30%,画龙点睛的重点高亮(如关键数字、重点结论词、边框线装饰)占 10%。\n\n2. 视觉层级与字号梯度(Visual Hierarchy & Text Scales):\n - 建立清晰鲜明的字号差,中文默认使用“微软雅黑” (Microsoft YaHei),英文默认使用“Segoe UI”:\n * 幻灯片大标题(Slide Title):36pt - 44pt,粗体 (Bold),深色高对比度。\n * 章节/卡片小标题(Card Header):20pt - 24pt,粗体。\n * 正文内容(Body Text):14pt - 18pt,常规体 (Regular),中等对比度(如深灰)。\n * 关键数据/大数字(Big Stat/Metric):48pt - 72pt 粗体,可搭配 12pt 的解释性小字。\n - 为确保文字排版完美,对所有新创建的文本框设置以下属性以防文本溢出或不对齐:\n * shape.TextFrame.WordWrap = Office.MsoTriState.msoTrue; // 开启自动换行\n * 适当清除或缩小默认内边距:\n shape.TextFrame.MarginLeft = 10f;\n shape.TextFrame.MarginRight = 10f;\n shape.TextFrame.MarginTop = 10f;\n shape.TextFrame.MarginBottom = 10f;\n\n3. 负空间与留白(Negative Space & Whitespace):\n - 不要把幻灯片塞满!页面必须保留至少 30% - 40% 的空白区域,让观众的眼睛有呼吸的空间。\n - 绝不允许使用大段文字,请将文字提炼为短句,以列表或卡片形式展现。\n\n4. 动态网格布局与防重叠(Dynamic Grid Layout & Math Positioning):\n - 永远通过代码动态获取幻灯片的物理大小,并在此基础上进行百分比定位或网格分栏计算,严禁使用写死的绝对坐标导致在不同比例的PPT中排版错乱:\n float slideWidth = Application.ActivePresentation.PageSetup.SlideWidth;\n float slideHeight = Application.ActivePresentation.PageSetup.SlideHeight;\n - 留出四周安全边距(Margin,通常设为 slideWidth * 0.06f 与 slideHeight * 0.08f)。\n - 如果需要展示并列的多个观点,应自动计算分栏位置:\n * 两栏布局:左栏占宽 45%,右栏占宽 45%,中间留空 10%。\n * 三分栏卡片布局示例:\n float margin = slideWidth * 0.05f;\n float availableWidth = slideWidth - 2 * margin;\n float gap = 20f; // 卡片间距\n float cardWidth = (availableWidth - 2 * gap) / 3f;\n float cardHeight = slideHeight * 0.5f;\n float top = slideHeight * 0.3f;\n for (int i = 0; i < 3; i++) {\n float left = margin + i * (cardWidth + gap);\n // 在 left, top, cardWidth, cardHeight 处绘制卡片容器\n }\n - 通过数学计算绝对保证生成的文本框、容器形状之间不会重叠。\n\n5. 扁平化容器卡片化(Flat Card-Based Design):\n - 列表或并列观点应使用“浅色卡片”作为容器装载,卡片背景填充非常浅的颜色(如 RGB: 245, 245, 245),并且必须去掉边框轮廓以符合现代扁平化审美:\n cardShape.Line.Visible = Office.MsoTriState.msoFalse;\n cardShape.Fill.ForeColor.RGB = ColorTranslator.ToOle(Color.FromArgb(245, 245, 245));\n - 在卡片之上叠放文字形状时,通过 Y 坐标偏移(如 cardTop + 15 放置标题,cardTop + 45 放置正文)使排版整洁大方。\n - 尽量使用无轮廓(`Line.Visible = msoFalse`)的形状,如果使用线条,应保持极细(`Line.Weight = 1.0f`)且使用温和的淡灰色。\";\n }\n\n private void TxtInput_KeyDown(object sender, KeyEventArgs e)\n {\n if (e.KeyCode == Keys.Enter && !e.Shift)\n {\n e.SuppressKeyPress = true; // Prevent newline\n btnSend.PerformClick();\n }\n }\n\n private void ClearChat()\n {\n chatFlowLayout.Controls.Clear();\n conversationHistory.Clear();\n InitializeSystemMessage();\n }\n\n private void AppendBubble(string text, bool isUser, bool isSystem, bool isError = false)\n {\n // Strip emojis from the text to prevent square box characters in RichTextBox\n text = StripEmojis(text);\n\n ChatRowPanel row = new ChatRowPanel(text, isUser, isSystem, isError, chatFlowLayout.Width - 25);\n chatFlowLayout.Controls.Add(row);\n \n chatFlowLayout.PerformLayout();\n chatFlowLayout.ScrollControlIntoView(row);\n \n try\n {\n chatFlowLayout.VerticalScroll.Value = chatFlowLayout.VerticalScroll.Maximum;\n }\n catch {}\n }\n\n private static string StripEmojis(string text)\n {\n if (string.IsNullOrEmpty(text)) return text;\n try\n {\n // Remove surrogate pairs and symbol ranges (emojis, icons, private use areas)\n return Regex.Replace(text, @\"\\p{Cs}|\\p{So}|\\p{Cn}\", \"\");\n }\n catch\n {\n return text;\n }\n }\n\n private void UpdateBubbleWidths()\n {\n if (chatFlowLayout == null) return;\n chatFlowLayout.SuspendLayout();\n int targetWidth = chatFlowLayout.Width - 25;\n if (targetWidth < 100) targetWidth = 100;\n foreach (Control ctrl in chatFlowLayout.Controls)\n {\n if (ctrl is ChatRowPanel row)\n {\n row.UpdateWidth(targetWidth);\n }\n }\n chatFlowLayout.ResumeLayout(true);\n }\n\n private async Task HandleSendAsync()\n {\n if (isGenerating)\n {\n cts?.Cancel();\n return;\n }\n\n string message = txtInput.Text.Trim();\n if (string.IsNullOrEmpty(message)) return;\n\n if (string.IsNullOrEmpty(currentConfig.ApiKey))\n {\n MessageBox.Show(\"请先配置 API Key!\", \"提示\", MessageBoxButtons.OK, MessageBoxIcon.Warning);\n BtnSettings_Click(null, null);\n return;\n }\n\n txtInput.Text = \"\";\n AppendBubble(message, isUser: true, isSystem: false);\n conversationHistory.Add(new ChatMessage { Role = \"user\", Content = message });\n\n isGenerating = true;\n btnSend.Text = \"停止\";\n btnSend.BackColor = Color.FromArgb(209, 52, 56);\n txtInput.Enabled = false;\n btnClear.Enabled = false;\n btnSettings.Enabled = false;\n\n cts = new CancellationTokenSource();\n\n try\n {\n await RunAgentLoopAsync(cts.Token);\n }\n catch (OperationCanceledException)\n {\n AppendBubble(\"[已终止]: 用户中止了 AI 响应。\", isUser: false, isSystem: true, isError: false);\n }\n catch (Exception ex)\n {\n AppendBubble($\"[AI 调用出错]:\\n{ex.ToString()}\", isUser: false, isSystem: true, isError: true);\n }\n finally\n {\n isGenerating = false;\n btnSend.Text = \"发送\";\n btnSend.BackColor = Color.FromArgb(0, 120, 212);\n \n txtInput.Enabled = true;\n btnClear.Enabled = true;\n btnSettings.Enabled = true;\n\n cts?.Dispose();\n cts = null;\n }\n }\n\n private async Task RunAgentLoopAsync(CancellationToken token)\n {\n int maxIterations = 5;\n int currentIteration = 0;\n\n var tools = new List<ChatTool>\n {\n new ChatTool\n {\n Function = new ToolFunction\n {\n Name = \"execute_csharp_code\",\n Description = \"在幻灯片中动态编译并执行C#代码来操作系统。代码将被放置在 `public static void Run(PowerPoint.Application Application)` 方法体中运行。你可以使用任何 PowerPoint API 进行操作。\",\n Parameters = new ToolParameters\n {\n Properties = new Dictionary<string, object>\n {\n {\n \"code\", new Dictionary<string, string>\n {\n { \"type\", \"string\" },\n { \"description\", \"要执行的 C# 语句。必须使用合法的 C# 语法,不用声明 class 或 method,只写方法体内部的执行代码。\" }\n }\n }\n },\n Required = new List<string> { \"code\" }\n }\n }\n }\n };\n\n while (currentIteration < maxIterations)\n {\n token.ThrowIfCancellationRequested();\n currentIteration++;\n\n var request = new ChatRequest\n {\n Model = currentConfig.Model,\n Messages = conversationHistory,\n Tools = tools,\n ToolChoice = \"auto\"\n };\n\n string requestJson = JsonConvert.SerializeObject(request);\n var content = new StringContent(requestJson, Encoding.UTF8, \"application/json\");\n\n httpClient.DefaultRequestHeaders.Clear();\n httpClient.DefaultRequestHeaders.Add(\"Authorization\", $\"Bearer {currentConfig.ApiKey}\");\n\n string url = currentConfig.ApiUrl.TrimEnd('/') + \"/chat/completions\";\n \n HttpResponseMessage response;\n try\n {\n response = await httpClient.PostAsync(url, content, token);\n }\n catch (OperationCanceledException)\n {\n throw;\n }\n catch (Exception ex)\n {\n throw new Exception($\"无法连接到 AI 服务,网络连接异常: {ex.Message}\\n请求地址: {url}\\n\\n详细信息:\\n{ex.ToString()}\");\n }\n\n token.ThrowIfCancellationRequested();\n string responseJson = await response.Content.ReadAsStringAsync();\n if (!response.IsSuccessStatusCode)\n {\n string errorDetails = \"\";\n try\n {\n var errRes = JsonConvert.DeserializeObject<ChatResponse>(responseJson);\n errorDetails = errRes?.Error?.Message;\n }\n catch {}\n if (string.IsNullOrEmpty(errorDetails))\n {\n errorDetails = responseJson;\n }\n throw new Exception($\"API 请求失败 (HTTP {response.StatusCode} - {response.ReasonPhrase}):\\n{errorDetails}\");\n }\n\n var chatRes = JsonConvert.DeserializeObject<ChatResponse>(responseJson);\n if (chatRes == null || chatRes.Choices == null || chatRes.Choices.Count == 0)\n {\n throw new Exception(\"API 返回了空响应,无法处理。\");\n }\n\n var choiceMessage = chatRes.Choices[0].Message;\n conversationHistory.Add(choiceMessage);\n\n if (!string.IsNullOrEmpty(choiceMessage.Content))\n {\n AppendBubble(choiceMessage.Content, isUser: false, isSystem: false);\n }\n\n if (choiceMessage.ToolCalls != null && choiceMessage.ToolCalls.Count > 0)\n {\n token.ThrowIfCancellationRequested();\n foreach (var toolCall in choiceMessage.ToolCalls)\n {\n if (toolCall.Function.Name == \"execute_csharp_code\")\n {\n string code = \"\";\n try\n {\n var args = JsonConvert.DeserializeObject<Dictionary<string, string>>(toolCall.Function.Arguments);\n if (args != null && args.TryGetValue(\"code\", out string argCode))\n {\n code = argCode;\n }\n }\n catch (Exception ex)\n {\n string toolErr = $\"解析参数失败: {ex.Message}\";\n conversationHistory.Add(new ChatMessage\n {\n Role = \"tool\",\n Name = \"execute_csharp_code\",\n Content = toolErr,\n ToolCallId = toolCall.Id\n });\n AppendBubble($\"[工具调用解析失败]\\n{toolErr}\", isUser: false, isSystem: true, isError: true);\n continue;\n }\n\n AppendBubble($\"[AI 请求执行 C# 代码]:\\n{code}\", isUser: false, isSystem: true);\n\n string runResult = \"\";\n try\n {\n runResult = CSharpExecutor.Execute(code, Globals.ThisAddIn.Application);\n }\n catch (Exception ex)\n {\n runResult = $\"Critical Runtime Error: {ex.Message}\";\n }\n\n conversationHistory.Add(new ChatMessage\n {\n Role = \"tool\",\n Name = \"execute_csharp_code\",\n Content = runResult,\n ToolCallId = toolCall.Id\n });\n\n if (runResult == \"Success\")\n {\n AppendBubble(\"[执行结果]: 成功!\", isUser: false, isSystem: true);\n }\n else\n {\n AppendBubble($\"[执行结果]: 失败!\\n{runResult}\", isUser: false, isSystem: true, isError: true);\n }\n }\n }\n continue;\n }\n\n break;\n }\n }\n\n public static Bitmap CreateIcon(string type, int size, Color color)\n {\n Bitmap bmp = new Bitmap(size, size);\n using (Graphics g = Graphics.FromImage(bmp))\n {\n g.SmoothingMode = SmoothingMode.AntiAlias;\n g.Clear(Color.Transparent);\n using (Pen pen = new Pen(color, 1.8f))\n using (Brush brush = new SolidBrush(color))\n {\n if (type == \"settings\")\n {\n int r1 = size / 2 - 1;\n int r2 = size / 2 - 4;\n int cx = size / 2;\n int cy = size / 2;\n g.DrawEllipse(pen, cx - r2, cy - r2, r2 * 2, r2 * 2);\n \n for (int i = 0; i < 8; i++)\n {\n double angle = i * Math.PI / 4;\n float x1 = (float)(cx + r2 * Math.Cos(angle));\n float y1 = (float)(cy + r2 * Math.Sin(angle));\n float x2 = (float)(cx + r1 * Math.Cos(angle));\n float y2 = (float)(cy + r1 * Math.Sin(angle));\n g.DrawLine(pen, x1, y1, x2, y2);\n }\n }\n else if (type == \"trash\")\n {\n g.DrawRectangle(pen, 4, 5, size - 9, size - 8);\n g.DrawLine(pen, 2, 3, size - 3, 3);\n g.DrawLine(pen, 6, 1, size - 7, 1);\n g.DrawLine(pen, 7, 7, 7, size - 5);\n g.DrawLine(pen, size / 2, 7, size / 2, size - 5);\n g.DrawLine(pen, size - 8, 7, size - 8, size - 5);\n }\n }\n }\n return bmp;\n }\n }\n\n // --- Settings Dialog Form ---\n public class AISettingsForm : Form\n {\n private ComboBox cmbPresets;\n private Button btnAddPreset;\n private Button btnDeletePreset;\n private TextBox txtPresetName;\n private TextBox txtApiUrl;\n private TextBox txtApiKey;\n private CheckBox chkShowKey;\n private TextBox txtModel;\n private TextBox txtSystemPrompt;\n private Button btnSave;\n private Button btnCancel;\n private Button btnRestoreDefault;\n private AIConfig config;\n private List<AIPreset> tempPresets;\n private string activePresetName;\n private AIPreset currentEditingPreset = null;\n\n public AISettingsForm(AIConfig currentConfig)\n {\n this.config = currentConfig;\n this.tempPresets = new List<AIPreset>();\n foreach (var p in currentConfig.Presets)\n tempPresets.Add(new AIPreset { Name = p.Name, ApiUrl = p.ApiUrl, ApiKey = p.ApiKey, Model = p.Model, SystemPrompt = p.SystemPrompt });\n this.activePresetName = currentConfig.CurrentPresetName;\n InitializeComponent();\n LoadPresetsToCombo();\n }\n\n private void InitializeComponent()\n {\n this.Text = \"AI 助手设置\";\n this.Size = new Size(620, 580);\n this.FormBorderStyle = FormBorderStyle.FixedDialog;\n this.MaximizeBox = false;\n this.MinimizeBox = false;\n this.StartPosition = FormStartPosition.CenterParent;\n this.Font = new Font(\"Microsoft YaHei\", 9F);\n this.BackColor = Color.White;\n\n Label lblSelectPreset = new Label { Text = \"选择预设:\", Location = new Point(15, 20), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n cmbPresets = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Location = new Point(100, 20), Width = 280, Height = 24 };\n\n btnAddPreset = new Button { Text = \"新增\", Location = new Point(390, 18), Width = 90, Height = 28, BackColor = Color.FromArgb(240, 240, 240), FlatStyle = FlatStyle.Flat };\n btnAddPreset.FlatAppearance.BorderColor = Color.LightGray;\n btnDeletePreset = new Button { Text = \"删除\", Location = new Point(490, 18), Width = 90, Height = 28, BackColor = Color.FromArgb(240, 240, 240), FlatStyle = FlatStyle.Flat };\n btnDeletePreset.FlatAppearance.BorderColor = Color.LightGray;\n\n Label lblPresetName = new Label { Text = \"预设名称:\", Location = new Point(15, 60), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtPresetName = new TextBox { Location = new Point(100, 60), Width = 480 };\n txtPresetName.TextChanged += TxtPresetName_TextChanged;\n\n Label lblUrl = new Label { Text = \"API 地址:\", Location = new Point(15, 100), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtApiUrl = new TextBox { Location = new Point(100, 100), Width = 480 };\n\n Label lblKey = new Label { Text = \"API Key:\", Location = new Point(15, 140), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtApiKey = new TextBox { Location = new Point(100, 140), Width = 400, PasswordChar = '*' };\n chkShowKey = new CheckBox { Text = \"显示\", Location = new Point(510, 140), Width = 70, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n chkShowKey.CheckedChanged += (s, e) => { txtApiKey.PasswordChar = chkShowKey.Checked ? '\\0' : '*'; };\n\n Label lblModel = new Label { Text = \"模型名称:\", Location = new Point(15, 180), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtModel = new TextBox { Location = new Point(100, 180), Width = 480 };\n\n Label lblPrompt = new Label { Text = \"系统提示词:\", Location = new Point(15, 220), Width = 85, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtSystemPrompt = new TextBox { Location = new Point(100, 220), Width = 480, Height = 240, Multiline = true, ScrollBars = ScrollBars.Vertical, Font = new Font(\"Microsoft YaHei\", 9F) };\n\n btnRestoreDefault = new Button { Text = \"恢复默认提示词\", Location = new Point(100, 485), Width = 150, Height = 28, FlatStyle = FlatStyle.Flat };\n btnRestoreDefault.FlatAppearance.BorderColor = Color.LightGray;\n btnRestoreDefault.Click += (s, e) => { txtSystemPrompt.Text = AISidebarControl.GetDefaultSystemPrompt(); };\n\n btnSave = new Button { Text = \"保存\", DialogResult = DialogResult.OK, Location = new Point(380, 485), Width = 90, Height = 28, BackColor = Color.FromArgb(0, 120, 212), ForeColor = Color.White, FlatStyle = FlatStyle.Flat };\n btnSave.FlatAppearance.BorderSize = 0;\n btnCancel = new Button { Text = \"取消\", DialogResult = DialogResult.Cancel, Location = new Point(490, 485), Width = 90, Height = 28, FlatStyle = FlatStyle.Flat };\n btnCancel.FlatAppearance.BorderColor = Color.LightGray;\n\n this.Controls.AddRange(new Control[] {\n lblSelectPreset, cmbPresets, btnAddPreset, btnDeletePreset,\n lblPresetName, txtPresetName, lblUrl, txtApiUrl,\n lblKey, txtApiKey, chkShowKey, lblModel, txtModel,\n lblPrompt, txtSystemPrompt, btnRestoreDefault, btnSave, btnCancel\n });\n this.AcceptButton = btnSave;\n this.CancelButton = btnCancel;\n btnAddPreset.Click += BtnAddPreset_Click;\n btnDeletePreset.Click += BtnDeletePreset_Click;\n }\n\n private void LoadPresetsToCombo()\n {\n cmbPresets.SelectedIndexChanged -= CmbPresets_SelectedIndexChanged;\n cmbPresets.DataSource = null;\n cmbPresets.DataSource = tempPresets;\n cmbPresets.DisplayMember = \"Name\";\n int idx = tempPresets.FindIndex(p => p.Name == activePresetName);\n if (idx < 0 && tempPresets.Count > 0) idx = 0;\n cmbPresets.SelectedIndex = idx;\n if (idx >= 0) { LoadPresetToFields(tempPresets[idx]); activePresetName = tempPresets[idx].Name; }\n cmbPresets.SelectedIndexChanged += CmbPresets_SelectedIndexChanged;\n }\n\n private void SaveCurrentPresetEdits()\n {\n if (currentEditingPreset != null)\n {\n currentEditingPreset.Name = txtPresetName.Text.Trim();\n currentEditingPreset.ApiUrl = txtApiUrl.Text.Trim();\n currentEditingPreset.ApiKey = txtApiKey.Text.Trim();\n currentEditingPreset.Model = txtModel.Text.Trim();\n currentEditingPreset.SystemPrompt = txtSystemPrompt.Text;\n }\n }\n\n private void LoadPresetToFields(AIPreset preset)\n {\n currentEditingPreset = preset;\n if (preset != null)\n {\n txtPresetName.Text = preset.Name;\n txtApiUrl.Text = preset.ApiUrl;\n txtApiKey.Text = preset.ApiKey;\n txtModel.Text = preset.Model;\n txtSystemPrompt.Text = preset.SystemPrompt;\n }\n }\n\n private void CmbPresets_SelectedIndexChanged(object sender, EventArgs e)\n {\n SaveCurrentPresetEdits();\n if (cmbPresets.SelectedIndex >= 0)\n {\n var selected = cmbPresets.SelectedItem as AIPreset;\n LoadPresetToFields(selected);\n this.activePresetName = selected?.Name ?? \"\";\n }\n }\n\n private void TxtPresetName_TextChanged(object sender, EventArgs e)\n {\n if (currentEditingPreset != null && cmbPresets.SelectedItem == currentEditingPreset)\n {\n string newName = txtPresetName.Text.Trim();\n if (!string.IsNullOrEmpty(newName) && currentEditingPreset.Name != newName)\n {\n currentEditingPreset.Name = newName;\n int idx = cmbPresets.SelectedIndex;\n cmbPresets.SelectedIndexChanged -= CmbPresets_SelectedIndexChanged;\n cmbPresets.DataSource = null;\n cmbPresets.DataSource = tempPresets;\n cmbPresets.DisplayMember = \"Name\";\n cmbPresets.SelectedIndex = idx;\n cmbPresets.SelectedIndexChanged += CmbPresets_SelectedIndexChanged;\n }\n }\n }\n\n private void BtnAddPreset_Click(object sender, EventArgs e)\n {\n SaveCurrentPresetEdits();\n int count = 1;\n string newName = \"New Preset\";\n while (tempPresets.Exists(p => p.Name == $\"{newName} {count}\")) count++;\n string finalName = $\"{newName} {count}\";\n var newPreset = new AIPreset { Name = finalName, ApiUrl = \"https://api.openai.com/v1\", ApiKey = \"\", Model = \"gpt-4o\", SystemPrompt = AISidebarControl.GetDefaultSystemPrompt() };\n tempPresets.Add(newPreset);\n activePresetName = finalName;\n LoadPresetsToCombo();\n }\n\n private void BtnDeletePreset_Click(object sender, EventArgs e)\n {\n if (tempPresets.Count <= 1) { MessageBox.Show(\"必须保留至少一个预设!\", \"提示\", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }\n if (cmbPresets.SelectedItem is AIPreset selected)\n {\n if (MessageBox.Show($\"确定删除预设 '{selected.Name}' 吗?\", \"确认删除\", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)\n {\n tempPresets.Remove(selected);\n currentEditingPreset = null;\n activePresetName = tempPresets[0].Name;\n LoadPresetsToCombo();\n }\n }\n }\n\n public void UpdateConfig(AIConfig targetConfig)\n {\n SaveCurrentPresetEdits();\n targetConfig.Presets = this.tempPresets;\n targetConfig.CurrentPresetName = this.activePresetName;\n }\n }\n\n // --- Data Models for API ---\n public class AIPreset\n {\n public string Name { get; set; } = \"Default\";\n public string ApiUrl { get; set; } = \"https://api.openai.com/v1\";\n public string ApiKey { get; set; } = \"\";\n public string Model { get; set; } = \"gpt-4o\";\n public string SystemPrompt { get; set; } = \"\";\n public override string ToString() => Name;\n }\n\n public class AIConfig\n {\n public List<AIPreset> Presets { get; set; } = new List<AIPreset>();\n public string CurrentPresetName { get; set; } = \"\";\n\n [JsonIgnore]\n public AIPreset CurrentPreset\n {\n get\n {\n if (Presets == null || Presets.Count == 0)\n {\n Presets = new List<AIPreset> { new AIPreset { Name = \"Default\" } };\n CurrentPresetName = \"Default\";\n }\n var active = Presets.Find(p => p.Name == CurrentPresetName);\n if (active == null) { active = Presets[0]; CurrentPresetName = active.Name; }\n return active;\n }\n }\n\n [JsonIgnore] public string ApiUrl { get => CurrentPreset.ApiUrl; set => CurrentPreset.ApiUrl = value; }\n [JsonIgnore] public string ApiKey { get => CurrentPreset.ApiKey; set => CurrentPreset.ApiKey = value; }\n [JsonIgnore] public string Model { get => CurrentPreset.Model; set => CurrentPreset.Model = value; }\n [JsonIgnore] public string SystemPrompt { get => CurrentPreset.SystemPrompt; set => CurrentPreset.SystemPrompt = value; }\n }\n\n public class ChatRequest\n {\n [JsonProperty(\"model\")]\n public string Model { get; set; }\n [JsonProperty(\"messages\")]\n public List<ChatMessage> Messages { get; set; }\n [JsonProperty(\"tools\", NullValueHandling = NullValueHandling.Ignore)]\n public List<ChatTool> Tools { get; set; }\n [JsonProperty(\"tool_choice\", NullValueHandling = NullValueHandling.Ignore)]\n public string ToolChoice { get; set; }\n }\n\n public class ChatMessage\n {\n [JsonProperty(\"role\")]\n public string Role { get; set; }\n [JsonProperty(\"content\", NullValueHandling = NullValueHandling.Ignore)]\n public string Content { get; set; }\n [JsonProperty(\"reasoning_content\", NullValueHandling = NullValueHandling.Ignore)]\n public string ReasoningContent { get; set; }\n [JsonProperty(\"tool_calls\", NullValueHandling = NullValueHandling.Ignore)]\n public List<ToolCall> ToolCalls { get; set; }\n [JsonProperty(\"tool_call_id\", NullValueHandling = NullValueHandling.Ignore)]\n public string ToolCallId { get; set; }\n [JsonProperty(\"name\", NullValueHandling = NullValueHandling.Ignore)]\n public string Name { get; set; }\n }\n\n public class ChatTool\n {\n [JsonProperty(\"type\")]\n public string Type { get; set; } = \"function\";\n [JsonProperty(\"function\")]\n public ToolFunction Function { get; set; }\n }\n\n public class ToolFunction\n {\n [JsonProperty(\"name\")]\n public string Name { get; set; }\n [JsonProperty(\"description\")]\n public string Description { get; set; }\n [JsonProperty(\"parameters\")]\n public ToolParameters Parameters { get; set; }\n }\n\n public class ToolParameters\n {\n [JsonProperty(\"type\")]\n public string Type { get; set; } = \"object\";\n [JsonProperty(\"properties\")]\n public Dictionary<string, object> Properties { get; set; }\n [JsonProperty(\"required\")]\n public List<string> Required { get; set; }\n }\n\n public class ToolCall\n {\n [JsonProperty(\"id\")]\n public string Id { get; set; }\n [JsonProperty(\"type\")]\n public string Type { get; set; }\n [JsonProperty(\"function\")]\n public ToolCallFunction Function { get; set; }\n }\n\n public class ToolCallFunction\n {\n [JsonProperty(\"name\")]\n public string Name { get; set; }\n [JsonProperty(\"arguments\")]\n public string Arguments { get; set; }\n }\n\n public class ChatResponse\n {\n [JsonProperty(\"choices\")]\n public List<ChatChoice> Choices { get; set; }\n [JsonProperty(\"error\")]\n public ApiError Error { get; set; }\n }\n\n public class ChatChoice\n {\n [JsonProperty(\"message\")]\n public ChatMessage Message { get; set; }\n }\n\n public class ApiError\n {\n [JsonProperty(\"message\")]\n public string Message { get; set; }\n }\n\n // --- Custom UI Controls ---\n public class ChatRowPanel : Panel\n {\n [System.Runtime.InteropServices.DllImport(\"user32.dll\")]\n private static extern bool HideCaret(IntPtr hWnd);\n\n private Panel bubblePanel;\n private RichTextBox rtbText;\n private bool isUser;\n private bool isSystem;\n private bool isError;\n\n // Collapsible properties\n private bool isCollapsible = false;\n private bool isExpanded = false;\n private string headerText = \"\";\n private string bodyText = \"\";\n private Panel headerPanel;\n private Button btnToggle;\n\n public ChatRowPanel(string text, bool isUser, bool isSystem, bool isError, int width)\n {\n this.isUser = isUser;\n this.isSystem = isSystem;\n this.isError = isError;\n this.Width = width;\n this.BackColor = Color.Transparent;\n\n // Check if it is a collapsible code block or output log\n if (isSystem && (text.StartsWith(\"[AI 请求执行 C# 代码]:\") || text.StartsWith(\"[执行结果]:\")))\n {\n int firstNewLine = text.IndexOf('\\n');\n if (firstNewLine >= 0)\n {\n headerText = text.Substring(0, firstNewLine).TrimEnd(':');\n bodyText = text.Substring(firstNewLine + 1);\n isCollapsible = !string.IsNullOrWhiteSpace(bodyText);\n }\n else\n {\n headerText = text;\n bodyText = \"\";\n isCollapsible = false;\n }\n isExpanded = false; // Collapsed by default\n }\n\n bubblePanel = new Panel\n {\n BackColor = Color.Transparent,\n Padding = new Padding(10, 8, 10, 8),\n AutoSize = false\n };\n\n rtbText = new RichTextBox\n {\n BorderStyle = BorderStyle.None,\n ScrollBars = RichTextBoxScrollBars.None,\n ReadOnly = true,\n Multiline = true,\n WordWrap = true,\n BackColor = Color.White,\n Font = new Font(\"Microsoft YaHei\", 9F)\n };\n\n rtbText.GotFocus += (s, e) => { HideCaret(rtbText.Handle); };\n rtbText.MouseDown += (s, e) => { HideCaret(rtbText.Handle); };\n\n if (isCollapsible)\n {\n headerPanel = new Panel\n {\n Height = 24,\n BackColor = Color.Transparent,\n Location = new Point(10, 6)\n };\n\n Label lblHeader = new Label\n {\n Text = headerText,\n Font = new Font(\"Microsoft YaHei\", 9F, FontStyle.Bold),\n ForeColor = isError ? Color.FromArgb(180, 40, 40) : Color.FromArgb(80, 80, 80),\n Location = new Point(0, 3),\n AutoSize = true\n };\n headerPanel.Controls.Add(lblHeader);\n\n btnToggle = new Button\n {\n Text = \"▶ 展开\",\n Font = new Font(\"Microsoft YaHei\", 8F),\n FlatStyle = FlatStyle.Flat,\n BackColor = Color.Transparent,\n ForeColor = Color.FromArgb(0, 120, 212),\n Location = new Point(lblHeader.Right + 6, 1),\n Width = 48,\n Height = 22,\n Cursor = Cursors.Hand\n };\n btnToggle.FlatAppearance.BorderSize = 0;\n btnToggle.FlatAppearance.MouseOverBackColor = Color.FromArgb(230, 230, 230);\n btnToggle.Click += BtnToggle_Click;\n headerPanel.Controls.Add(btnToggle);\n\n lblHeader.SizeChanged += (s, e) => { btnToggle.Left = lblHeader.Right + 6; };\n\n bubblePanel.Controls.Add(headerPanel);\n\n rtbText.Text = bodyText;\n rtbText.Font = new Font(\"Consolas\", 8.5F);\n rtbText.ForeColor = Color.FromArgb(60, 60, 60);\n rtbText.Location = new Point(10, 32);\n rtbText.Visible = false;\n\n bubblePanel.Controls.Add(rtbText);\n }\n else\n {\n FormatMarkdown(rtbText, text, isUser);\n rtbText.Location = new Point(10, 8);\n bubblePanel.Controls.Add(rtbText);\n }\n\n bubblePanel.Paint += BubblePanel_Paint;\n this.Controls.Add(bubblePanel);\n UpdatePosition(width);\n }\n\n private void BtnToggle_Click(object sender, EventArgs e)\n {\n isExpanded = !isExpanded;\n rtbText.Visible = isExpanded;\n btnToggle.Text = isExpanded ? \"▼ 收起\" : \"▶ 展开\";\n \n UpdatePosition(this.Width);\n \n if (this.Parent is FlowLayoutPanel flp)\n {\n flp.PerformLayout();\n }\n }\n\n private void BubblePanel_Paint(object sender, PaintEventArgs e)\n {\n e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;\n Color backColor;\n if (isError) backColor = Color.FromArgb(253, 231, 233);\n else if (isSystem) backColor = Color.FromArgb(240, 240, 240);\n else if (isUser) backColor = Color.FromArgb(0, 120, 212);\n else backColor = Color.White;\n\n using (GraphicsPath path = GetRoundedPath(new Rectangle(0, 0, bubblePanel.Width - 1, bubblePanel.Height - 1), 8))\n using (SolidBrush brush = new SolidBrush(backColor))\n {\n e.Graphics.FillPath(brush, path);\n }\n\n if (!isUser && !isError)\n {\n using (GraphicsPath path = GetRoundedPath(new Rectangle(0, 0, bubblePanel.Width - 1, bubblePanel.Height - 1), 8))\n using (Pen pen = new Pen(Color.FromArgb(220, 220, 220), 1))\n {\n e.Graphics.DrawPath(pen, path);\n }\n }\n }\n\n private GraphicsPath GetRoundedPath(Rectangle rect, int radius)\n {\n GraphicsPath path = new GraphicsPath();\n int d = radius * 2;\n path.AddArc(rect.X, rect.Y, d, d, 180, 90);\n path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90);\n path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90);\n path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90);\n path.CloseFigure();\n return path;\n }\n\n public void UpdateWidth(int width)\n {\n this.Width = width;\n UpdatePosition(width);\n }\n\n private void UpdatePosition(int width)\n {\n int maxBubbleWidth = Convert.ToInt32(width * 0.85);\n int innerPaddingWidth = 20;\n\n int textWidth = maxBubbleWidth - innerPaddingWidth;\n if (textWidth < 50) textWidth = 50;\n\n // 1. Calculate the final bubble and RichTextBox widths\n Size measuredSize = TextRenderer.MeasureText(rtbText.Text, rtbText.Font, new Size(textWidth - 20, 0), TextFormatFlags.WordBreak);\n \n int finalBubbleWidth;\n if (isCollapsible)\n {\n finalBubbleWidth = maxBubbleWidth;\n }\n else\n {\n finalBubbleWidth = Math.Min(measuredSize.Width + innerPaddingWidth + 10, maxBubbleWidth);\n }\n\n int finalRtbWidth = finalBubbleWidth - innerPaddingWidth;\n if (finalRtbWidth < 50) finalRtbWidth = 50;\n\n rtbText.Width = finalRtbWidth;\n\n // Force handle creation so GetPositionFromCharIndex is completely accurate\n var handle = rtbText.Handle;\n\n // 2. Measure precise text wrapping height natively\n int calculatedHeight = 0;\n if (!string.IsNullOrEmpty(rtbText.Text))\n {\n Point pos = rtbText.GetPositionFromCharIndex(rtbText.Text.Length - 1);\n \n // Fallback if handle isn't fully ready or pos.Y returns 0 unexpectedly for multi-line text\n if (pos.Y == 0 && rtbText.Text.Contains(\"\\n\"))\n {\n int lineCount = rtbText.Lines.Length;\n int listBonus = 0;\n foreach (var line in rtbText.Lines)\n {\n if (line.TrimStart().StartsWith(\"- \") || line.TrimStart().StartsWith(\"* \"))\n {\n listBonus += 15;\n }\n }\n int boldAndFontPadding = 24 + (lineCount * 4) + listBonus;\n calculatedHeight = measuredSize.Height + boldAndFontPadding;\n }\n else\n {\n calculatedHeight = pos.Y + rtbText.Font.Height + 16;\n }\n }\n\n // 3. Set control heights and layout bounds\n if (isCollapsible)\n {\n headerPanel.Width = finalRtbWidth;\n if (isExpanded)\n {\n rtbText.Height = calculatedHeight;\n bubblePanel.Width = maxBubbleWidth;\n bubblePanel.Height = rtbText.Bottom + 8;\n }\n else\n {\n bubblePanel.Width = maxBubbleWidth;\n bubblePanel.Height = headerPanel.Bottom + 6;\n }\n }\n else\n {\n rtbText.Height = calculatedHeight;\n bubblePanel.Width = finalBubbleWidth;\n bubblePanel.Height = rtbText.Height + 16;\n }\n\n Color bubbleColor;\n if (isError) bubbleColor = Color.FromArgb(253, 231, 233);\n else if (isSystem) bubbleColor = Color.FromArgb(240, 240, 240);\n else if (isUser) bubbleColor = Color.FromArgb(0, 120, 212);\n else bubbleColor = Color.White;\n\n rtbText.BackColor = bubbleColor;\n rtbText.ForeColor = isUser ? Color.White : Color.FromArgb(33, 33, 33);\n\n this.Height = bubblePanel.Height + 10;\n\n if (isUser)\n {\n bubblePanel.Left = width - bubblePanel.Width - 5;\n }\n else\n {\n bubblePanel.Left = 5;\n }\n bubblePanel.Top = 5;\n \n bubblePanel.Invalidate();\n }\n\n private static void FormatMarkdown(RichTextBox rtb, string markdown, bool isUser)\n {\n rtb.Text = \"\";\n rtb.SelectionBullet = false;\n rtb.SelectionIndent = 0;\n\n string[] lines = markdown.Split('\\n');\n for (int l = 0; l < lines.Length; l++)\n {\n string line = lines[l];\n \n if (line.TrimStart().StartsWith(\"- \") || line.TrimStart().StartsWith(\"* \"))\n {\n int bulletIndex = line.IndexOf('-');\n if (bulletIndex < 0) bulletIndex = line.IndexOf('*');\n line = line.Substring(bulletIndex + 2);\n rtb.SelectionBullet = true;\n rtb.SelectionIndent = 12;\n }\n else\n {\n rtb.SelectionBullet = false;\n rtb.SelectionIndent = 0;\n }\n\n int i = 0;\n while (i < line.Length)\n {\n if (i + 1 < line.Length && line[i] == '*' && line[i + 1] == '*')\n {\n int next = line.IndexOf(\"**\", i + 2);\n if (next >= 0)\n {\n string boldText = line.Substring(i + 2, next - (i + 2));\n rtb.SelectionFont = new Font(rtb.Font, FontStyle.Bold);\n rtb.AppendText(boldText);\n i = next + 2;\n }\n else\n {\n rtb.SelectionFont = new Font(rtb.Font, FontStyle.Regular);\n rtb.AppendText(\"**\");\n i += 2;\n }\n }\n else if (line[i] == '`')\n {\n int next = line.IndexOf('`', i + 1);\n if (next >= 0)\n {\n string codeText = line.Substring(i + 1, next - (i + 1));\n \n rtb.SelectionFont = new Font(\"Consolas\", rtb.Font.Size);\n rtb.SelectionBackColor = isUser ? Color.FromArgb(0, 60, 120) : Color.FromArgb(228, 229, 230);\n rtb.SelectionColor = isUser ? Color.FromArgb(200, 230, 255) : Color.FromArgb(190, 50, 50);\n\n rtb.AppendText(codeText);\n \n rtb.SelectionBackColor = rtb.BackColor;\n rtb.SelectionColor = rtb.ForeColor;\n \n i = next + 1;\n }\n else\n {\n rtb.SelectionFont = new Font(rtb.Font, FontStyle.Regular);\n rtb.AppendText(\"`\");\n i++;\n }\n }\n else\n {\n rtb.SelectionFont = new Font(rtb.Font, FontStyle.Regular);\n rtb.SelectionColor = isUser ? Color.White : Color.FromArgb(33, 33, 33);\n rtb.AppendText(line[i].ToString());\n i++;\n }\n }\n\n if (l < lines.Length - 1)\n {\n rtb.AppendText(Environment.NewLine);\n }\n }\n }\n }\n}\n"}, "9cdb239520d74d34e14ee1600da7f5ae3470e87f": {"t": 1785760380.6516836, "n": 59906, "h": "9e04dab5d8dcad98", "c": "using System;\nusing System.Collections.Generic;\nusing System.Drawing;\nusing System.Drawing.Drawing2D;\nusing System.IO;\nusing System.Net.Http;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing System.Windows.Forms;\nusing Newtonsoft.Json;\nusing PowerPoint = Microsoft.Office.Interop.PowerPoint;\n\nnamespace SlideSCI\n{\n public class AISidebarControl : UserControl\n {\n private FlowLayoutPanel chatFlowLayout;\n \n private Panel inputPanel;\n private TextBox txtInput;\n private Button btnSend;\n private Button btnClear;\n private Button btnSettings;\n private ToolTip toolTip;\n\n private string configPath;\n private AIConfig currentConfig;\n private List<ChatMessage> conversationHistory = new List<ChatMessage>();\n private static readonly HttpClient httpClient = new HttpClient();\n private bool isGenerating = false;\n private CancellationTokenSource cts;\n\n public PowerPoint.DocumentWindow AssociatedWindow { get; set; }\n\n public AISidebarControl()\n {\n // Enable TLS 1.2 and TLS 1.3 for secure API calls\n try\n {\n System.Net.ServicePointManager.SecurityProtocol |= \n System.Net.SecurityProtocolType.Tls12 | \n System.Net.SecurityProtocolType.Tls11 |\n (System.Net.SecurityProtocolType)12288; // TLS 1.3\n }\n catch { }\n\n InitializeConfigPath();\n LoadConfig();\n InitializeComponent();\n InitializeSystemMessage();\n }\n\n private void InitializeConfigPath()\n {\n string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);\n string slideSciDir = Path.Combine(appData, \"SlideSCI\");\n if (!Directory.Exists(slideSciDir))\n {\n Directory.CreateDirectory(slideSciDir);\n }\n configPath = Path.Combine(slideSciDir, \"ai_config.json\");\n }\n\n private void LoadConfig()\n {\n if (File.Exists(configPath))\n {\n try\n {\n string json = File.ReadAllText(configPath);\n currentConfig = JsonConvert.DeserializeObject<AIConfig>(json);\n\n // Migration: If no presets exist but we have old single-config fields, create a default preset\n if (currentConfig != null && (currentConfig.Presets == null || currentConfig.Presets.Count == 0))\n {\n var dict = JsonConvert.DeserializeObject<Dictionary<string, object>>(json);\n string oldUrl = dict.ContainsKey(\"ApiUrl\") ? dict[\"ApiUrl\"]?.ToString() : null;\n string oldKey = dict.ContainsKey(\"ApiKey\") ? dict[\"ApiKey\"]?.ToString() : null;\n string oldModel = dict.ContainsKey(\"Model\") ? dict[\"Model\"]?.ToString() : null;\n string oldPrompt = dict.ContainsKey(\"SystemPrompt\") ? dict[\"SystemPrompt\"]?.ToString() : null;\n\n currentConfig.Presets = new List<AIPreset>();\n if (!string.IsNullOrEmpty(oldUrl) || !string.IsNullOrEmpty(oldKey))\n {\n currentConfig.Presets.Add(new AIPreset\n {\n Name = \"Default\",\n ApiUrl = oldUrl ?? \"https://api.openai.com/v1\",\n ApiKey = oldKey ?? \"\",\n Model = oldModel ?? \"gpt-4o\",\n SystemPrompt = oldPrompt ?? GetDefaultSystemPrompt()\n });\n currentConfig.CurrentPresetName = \"Default\";\n }\n }\n }\n catch\n {\n currentConfig = new AIConfig();\n }\n }\n else\n {\n currentConfig = new AIConfig();\n }\n\n if (currentConfig == null) currentConfig = new AIConfig();\n\n // Ensure we have some default presets if list is still empty\n if (currentConfig.Presets == null || currentConfig.Presets.Count == 0)\n {\n currentConfig.Presets = new List<AIPreset>\n {\n new AIPreset { Name = \"Kimi\", ApiUrl = \"https://api.moonshot.cn/v1\", Model = \"kimi-latest\", SystemPrompt = GetDefaultSystemPrompt() },\n new AIPreset { Name = \"DeepSeek\", ApiUrl = \"https://api.deepseek.com/v1\", Model = \"deepseek-chat\", SystemPrompt = GetDefaultSystemPrompt() },\n new AIPreset { Name = \"OpenAI\", ApiUrl = \"https://api.openai.com/v1\", Model = \"gpt-4o\", SystemPrompt = GetDefaultSystemPrompt() }\n };\n currentConfig.CurrentPresetName = \"Kimi\";\n SaveConfig();\n }\n\n if (string.IsNullOrWhiteSpace(currentConfig.SystemPrompt) ||\n currentConfig.SystemPrompt.Contains(\"Office.MsoShapeType.msoShapeRectangle\") ||\n currentConfig.SystemPrompt.Contains(\"msoShapeRoundRectangle\"))\n {\n currentConfig.SystemPrompt = GetDefaultSystemPrompt();\n SaveConfig();\n }\n }\n\n private void SaveConfig()\n {\n try\n {\n string json = JsonConvert.SerializeObject(currentConfig, Formatting.Indented);\n File.WriteAllText(configPath, json);\n }\n catch (Exception ex)\n {\n MessageBox.Show($\"保存设置失败: {ex.Message}\", \"错误\", MessageBoxButtons.OK, MessageBoxIcon.Error);\n }\n }\n\n private void InitializeComponent()\n {\n this.Dock = DockStyle.Fill;\n this.BackColor = Color.FromArgb(245, 246, 247);\n this.Font = new Font(\"Microsoft YaHei\", 9F, FontStyle.Regular, GraphicsUnit.Point);\n\n toolTip = new ToolTip();\n\n // 1. Input Panel (Docked at Bottom)\n inputPanel = new Panel\n {\n Dock = DockStyle.Bottom,\n Height = 105,\n BackColor = Color.White,\n Padding = new Padding(10, 8, 10, 8)\n };\n inputPanel.Paint += (s, e) =>\n {\n using (Pen pen = new Pen(Color.FromArgb(225, 225, 225), 1))\n {\n e.Graphics.DrawLine(pen, 0, 0, inputPanel.Width, 0);\n }\n };\n\n // Custom border panel for textbox\n Panel txtWrapper = new Panel\n {\n Location = new Point(10, 8),\n Width = this.Width - 20,\n Height = 52,\n BackColor = Color.White,\n Padding = new Padding(4)\n };\n txtWrapper.Paint += (s, e) =>\n {\n Color borderColor = txtInput.Focused ? Color.FromArgb(0, 120, 212) : Color.FromArgb(220, 220, 220);\n using (Pen pen = new Pen(borderColor, 1))\n {\n e.Graphics.DrawRectangle(pen, 0, 0, txtWrapper.Width - 1, txtWrapper.Height - 1);\n }\n };\n\n txtInput = new TextBox\n {\n Multiline = true,\n BorderStyle = BorderStyle.None,\n Dock = DockStyle.Fill,\n Font = new Font(\"Microsoft YaHei\", 9F),\n ScrollBars = ScrollBars.Vertical\n };\n txtInput.GotFocus += (s, e) => txtWrapper.Invalidate();\n txtInput.LostFocus += (s, e) => txtWrapper.Invalidate();\n txtInput.KeyDown += TxtInput_KeyDown;\n txtWrapper.Controls.Add(txtInput);\n inputPanel.Controls.Add(txtWrapper);\n\n // Icon-only Clear button\n btnClear = new Button\n {\n Image = CreateIcon(\"trash\", 16, Color.FromArgb(100, 100, 100)),\n FlatStyle = FlatStyle.Flat,\n BackColor = Color.Transparent,\n Location = new Point(10, 68),\n Width = 28,\n Height = 26,\n Cursor = Cursors.Hand\n };\n btnClear.FlatAppearance.BorderSize = 0;\n btnClear.FlatAppearance.MouseOverBackColor = Color.FromArgb(240, 240, 240);\n btnClear.Click += (s, e) => ClearChat();\n toolTip.SetToolTip(btnClear, \"清空对话\");\n inputPanel.Controls.Add(btnClear);\n\n // Icon-only Settings button\n btnSettings = new Button\n {\n Image = CreateIcon(\"settings\", 16, Color.FromArgb(100, 100, 100)),\n FlatStyle = FlatStyle.Flat,\n BackColor = Color.Transparent,\n Location = new Point(44, 68),\n Width = 28,\n Height = 26,\n Cursor = Cursors.Hand\n };\n btnSettings.FlatAppearance.BorderSize = 0;\n btnSettings.FlatAppearance.MouseOverBackColor = Color.FromArgb(240, 240, 240);\n btnSettings.Click += BtnSettings_Click;\n toolTip.SetToolTip(btnSettings, \"AI 设置\");\n inputPanel.Controls.Add(btnSettings);\n\n btnSend = new Button\n {\n Text = \"发送\",\n FlatStyle = FlatStyle.Flat,\n BackColor = Color.FromArgb(0, 120, 212),\n ForeColor = Color.White,\n Font = new Font(\"Microsoft YaHei\", 9F, FontStyle.Bold),\n Location = new Point(this.Width - 75, 68),\n Width = 65,\n Height = 26,\n Cursor = Cursors.Hand\n };\n btnSend.FlatAppearance.BorderSize = 0;\n btnSend.Click += async (s, e) => await HandleSendAsync();\n inputPanel.Controls.Add(btnSend);\n\n this.Controls.Add(inputPanel);\n\n // 2. Chat Flow Layout Panel (Docked Fill)\n chatFlowLayout = new FlowLayoutPanel\n {\n Dock = DockStyle.Fill,\n AutoScroll = true,\n FlowDirection = FlowDirection.LeftToRight,\n WrapContents = true,\n Padding = new Padding(8),\n BackColor = Color.FromArgb(245, 246, 247)\n };\n typeof(FlowLayoutPanel).InvokeMember(\"DoubleBuffered\",\n System.Reflection.BindingFlags.SetProperty | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,\n null, chatFlowLayout, new object[] { true });\n\n this.Controls.Add(chatFlowLayout);\n\n // Ensure Z-order prevents bottom layout overlapping\n chatFlowLayout.BringToFront();\n\n this.Resize += AISidebarControl_Resize;\n chatFlowLayout.Resize += (s, e) => UpdateBubbleWidths();\n }\n\n private void AISidebarControl_Resize(object sender, EventArgs e)\n {\n if (inputPanel != null)\n {\n foreach (Control ctrl in inputPanel.Controls)\n {\n if (ctrl is Panel p) // txtWrapper\n {\n p.Width = inputPanel.Width - 20;\n }\n else if (ctrl == btnSend)\n {\n btnSend.Left = inputPanel.Width - 75;\n }\n }\n }\n UpdateBubbleWidths();\n }\n\n private void BtnSettings_Click(object sender, EventArgs e)\n {\n using (var settingsForm = new AISettingsForm(currentConfig))\n {\n if (settingsForm.ShowDialog(this) == DialogResult.OK)\n {\n settingsForm.UpdateConfig(currentConfig);\n SaveConfig();\n\n // Update the active system message in the conversation history\n if (conversationHistory.Count > 0 && conversationHistory[0].Role == \"system\")\n {\n conversationHistory[0].Content = currentConfig.SystemPrompt;\n }\n }\n }\n }\n\n private void InitializeSystemMessage()\n {\n string systemPrompt = currentConfig.SystemPrompt;\n if (string.IsNullOrWhiteSpace(systemPrompt))\n {\n systemPrompt = GetDefaultSystemPrompt();\n currentConfig.SystemPrompt = systemPrompt;\n }\n\n conversationHistory.Add(new ChatMessage { Role = \"system\", Content = systemPrompt });\n AppendBubble(\"你好!我是你的 SlideSCI AI 助手。我可以执行以下命令:\\n1. 把选中的文字或文本框颜色改为红色、蓝色等。\\n2. 添加各种形状(矩形、圆形等)。\\n3. 新增一页空白幻灯片。\\n4. 将幻灯片中的指定文字进行查找替换。\\n\\n请在下方输入你的指令!\", isUser: false, isSystem: false);\n }\n\n public static string GetDefaultSystemPrompt()\n {\n return @\"你是一个PowerPoint AI助手,你可以通过编写和执行C#代码来帮用户操作当前幻灯片。\n你有调用 `execute_csharp_code` 工具的能力。\n当你需要对幻灯片进行操作时(如修改文字颜色、添加形状、新建页面、替换文字等),你应该写C#代码,然后通过调用 `execute_csharp_code` 来执行。\n\n编写C#代码时请遵守以下规则:\n1. 代码会在一个包含 `PowerPoint.Application Application` 参数的方法体内执行:`public static void Run(PowerPoint.Application Application)`。\n2. 已经默认导入了以下命名空间,请勿再在代码中写 using 声明:\n - System\n - System.Collections.Generic\n - System.Linq\n - System.Drawing\n - System.Windows.Forms\n - Microsoft.Office.Interop.PowerPoint (别名为 PowerPoint)\n - Microsoft.Office.Core (别名为 Office)\n3. 安全获取当前幻灯片对象 (Slide) 的方法:\n PowerPoint.Slide slide = null;\n try { slide = Application.ActiveWindow.View.Slide as PowerPoint.Slide; } catch {}\n if (slide == null) {\n try { slide = Application.ActivePresentation.Slides[Application.ActiveWindow.Selection.SlideRange[1].SlideIndex]; } catch {}\n }\n if (slide == null) {\n throw new Exception(\"\"没有找到活动的幻灯片,请确保PPT处于正常视图。\"\");\n }\n4. 新增幻灯片页面请直接对 Application.ActivePresentation.Slides 进行 Add 或 Add2 操作,例如:\n var pres = Application.ActivePresentation;\n pres.Slides.Add(pres.Slides.Count + 1, PowerPoint.PpSlideLayout.ppLayoutBlank);\n5. 操作文字颜色时,请使用 `System.Drawing.ColorTranslator.ToOle(Color.Red)` 将颜色转换为 OLE 颜色。例如:\n shape.TextFrame.TextRange.Font.Color.RGB = ColorTranslator.ToOle(Color.Red);\n6. 插入形状时,第一个参数必须是 `Office.MsoAutoShapeType` 枚举类型(注意:千万不可写错成 `Office.MsoShapeType`,否则会报编译错误)。常用的形状枚举如下:\n - 矩形:`Office.MsoAutoShapeType.msoShapeRectangle`\n - 圆角矩形(注意是 Rounded,不是 Round):`Office.MsoAutoShapeType.msoShapeRoundedRectangle`\n - 椭圆/圆形:`Office.MsoAutoShapeType.msoShapeOval`\n 示例:`slide.Shapes.AddShape(Office.MsoAutoShapeType.msoShapeRectangle, left, top, width, height)`。位置与尺寸参数皆为 float 类型。\n7. 修改文字内容时,如果需要查找并替换,可以遍历幻灯片中的 Shape 并检查 `shape.HasTextFrame == Office.MsoTriState.msoTrue && shape.TextFrame.HasText == Office.MsoTriState.msoTrue`。\n8. 请尽量编写健壮的代码,用 try-catch 包裹可能出错的地方,遇到不支持的操作时抛出异常。\n\n【核心PPT设计审美与排版规范(PPT Aesthetics & Layout Design Rules)】\n为了确保AI添加的PPT样式美观、专业且符合现代高端设计规范,请在编写代码生成/修改PPT内容时,严格遵守以下排版与审美原则:\n\n1. 配色系统与 60-30-10 黄金比例:\n - 禁止在没有用户明确要求的情况下使用刺眼的高饱和度纯原色(例如纯红 Color.Red、纯蓝 Color.Blue、纯绿 Color.Green、纯黄 Color.Yellow)。这会让 PPT 显得极不专业。\n - 使用成熟、高对比度且优雅的经典主题配色(使用 Color.FromArgb 定义颜色,然后使用 ColorTranslator.ToOle 转换):\n * 极简科技蓝主题:\n - 主背景/主色:浅灰白 Color.FromArgb(248, 249, 250) 或 极深蓝 Color.FromArgb(10, 34, 64)\n - 辅助色/卡片背景:浅天蓝 Color.FromArgb(232, 240, 254) 或 经典蓝 Color.FromArgb(26, 115, 232)\n - 点缀强调色(10%):温暖橙/金色 Color.FromArgb(245, 166, 35) 或 珊瑚红 Color.FromArgb(234, 67, 53)\n * 现代商务炭黑主题:\n - 主色:炭黑色 Color.FromArgb(43, 43, 43)\n - 辅助色:浅灰色 Color.FromArgb(240, 240, 240)\n - 点缀色:青/湖蓝色 Color.FromArgb(0, 168, 181)\n * 暖调人文/学术主题:\n - 主色:深林绿 Color.FromArgb(27, 67, 50)\n - 辅助色:柔和米色 Color.FromArgb(245, 242, 235)\n - 点缀色:赤陶红/土黄 Color.FromArgb(190, 130, 60)\n - 遵守 60-30-10 比例:背景占 60% 视觉面积,文字/卡片容器等次要元素占 30%,画龙点睛的重点高亮(如关键数字、重点结论词、边框线装饰)占 10%。\n\n2. 视觉层级与字号梯度(Visual Hierarchy & Text Scales):\n - 建立清晰鲜明的字号差,中文默认使用“微软雅黑” (Microsoft YaHei),英文默认使用“Segoe UI”:\n * 幻灯片大标题(Slide Title):36pt - 44pt,粗体 (Bold),深色高对比度。\n * 章节/卡片小标题(Card Header):20pt - 24pt,粗体。\n * 正文内容(Body Text):14pt - 18pt,常规体 (Regular),中等对比度(如深灰)。\n * 关键数据/大数字(Big Stat/Metric):48pt - 72pt 粗体,可搭配 12pt 的解释性小字。\n - 为确保文字排版完美,对所有新创建的文本框设置以下属性以防文本溢出或不对齐:\n * shape.TextFrame.WordWrap = Office.MsoTriState.msoTrue; // 开启自动换行\n * 适当清除或缩小默认内边距:\n shape.TextFrame.MarginLeft = 10f;\n shape.TextFrame.MarginRight = 10f;\n shape.TextFrame.MarginTop = 10f;\n shape.TextFrame.MarginBottom = 10f;\n\n3. 负空间与留白(Negative Space & Whitespace):\n - 不要把幻灯片塞满!页面必须保留至少 30% - 40% 的空白区域,让观众的眼睛有呼吸的空间。\n - 绝不允许使用大段文字,请将文字提炼为短句,以列表或卡片形式展现。\n\n4. 动态网格布局与防重叠(Dynamic Grid Layout & Math Positioning):\n - 永远通过代码动态获取幻灯片的物理大小,并在此基础上进行百分比定位或网格分栏计算,严禁使用写死的绝对坐标导致在不同比例的PPT中排版错乱:\n float slideWidth = Application.ActivePresentation.PageSetup.SlideWidth;\n float slideHeight = Application.ActivePresentation.PageSetup.SlideHeight;\n - 留出四周安全边距(Margin,通常设为 slideWidth * 0.06f 与 slideHeight * 0.08f)。\n - 如果需要展示并列的多个观点,应自动计算分栏位置:\n * 两栏布局:左栏占宽 45%,右栏占宽 45%,中间留空 10%。\n * 三分栏卡片布局示例:\n float margin = slideWidth * 0.05f;\n float availableWidth = slideWidth - 2 * margin;\n float gap = 20f; // 卡片间距\n float cardWidth = (availableWidth - 2 * gap) / 3f;\n float cardHeight = slideHeight * 0.5f;\n float top = slideHeight * 0.3f;\n for (int i = 0; i < 3; i++) {\n float left = margin + i * (cardWidth + gap);\n // 在 left, top, cardWidth, cardHeight 处绘制卡片容器\n }\n - 通过数学计算绝对保证生成的文本框、容器形状之间不会重叠。\n\n5. 扁平化容器卡片化(Flat Card-Based Design):\n - 列表或并列观点应使用“浅色卡片”作为容器装载,卡片背景填充非常浅的颜色(如 RGB: 245, 245, 245),并且必须去掉边框轮廓以符合现代扁平化审美:\n cardShape.Line.Visible = Office.MsoTriState.msoFalse;\n cardShape.Fill.ForeColor.RGB = ColorTranslator.ToOle(Color.FromArgb(245, 245, 245));\n - 在卡片之上叠放文字形状时,通过 Y 坐标偏移(如 cardTop + 15 放置标题,cardTop + 45 放置正文)使排版整洁大方。\n - 尽量使用无轮廓(`Line.Visible = msoFalse`)的形状,如果使用线条,应保持极细(`Line.Weight = 1.0f`)且使用温和的淡灰色。\";\n }\n\n private void TxtInput_KeyDown(object sender, KeyEventArgs e)\n {\n if (e.KeyCode == Keys.Enter && !e.Shift)\n {\n e.SuppressKeyPress = true; // Prevent newline\n btnSend.PerformClick();\n }\n }\n\n private void ClearChat()\n {\n chatFlowLayout.Controls.Clear();\n conversationHistory.Clear();\n InitializeSystemMessage();\n }\n\n private void AppendBubble(string text, bool isUser, bool isSystem, bool isError = false)\n {\n // Strip emojis from the text to prevent square box characters in RichTextBox\n text = StripEmojis(text);\n\n ChatRowPanel row = new ChatRowPanel(text, isUser, isSystem, isError, chatFlowLayout.Width - 25);\n chatFlowLayout.Controls.Add(row);\n \n chatFlowLayout.PerformLayout();\n chatFlowLayout.ScrollControlIntoView(row);\n \n try\n {\n chatFlowLayout.VerticalScroll.Value = chatFlowLayout.VerticalScroll.Maximum;\n }\n catch {}\n }\n\n private static string StripEmojis(string text)\n {\n if (string.IsNullOrEmpty(text)) return text;\n try\n {\n // Remove surrogate pairs and symbol ranges (emojis, icons, private use areas)\n return Regex.Replace(text, @\"\\p{Cs}|\\p{So}|\\p{Cn}\", \"\");\n }\n catch\n {\n return text;\n }\n }\n\n private void UpdateBubbleWidths()\n {\n if (chatFlowLayout == null) return;\n chatFlowLayout.SuspendLayout();\n int targetWidth = chatFlowLayout.Width - 25;\n if (targetWidth < 100) targetWidth = 100;\n foreach (Control ctrl in chatFlowLayout.Controls)\n {\n if (ctrl is ChatRowPanel row)\n {\n row.UpdateWidth(targetWidth);\n }\n }\n chatFlowLayout.ResumeLayout(true);\n }\n\n private async Task HandleSendAsync()\n {\n if (isGenerating)\n {\n cts?.Cancel();\n return;\n }\n\n string message = txtInput.Text.Trim();\n if (string.IsNullOrEmpty(message)) return;\n\n if (string.IsNullOrEmpty(currentConfig.ApiKey))\n {\n MessageBox.Show(\"请先配置 API Key!\", \"提示\", MessageBoxButtons.OK, MessageBoxIcon.Warning);\n BtnSettings_Click(null, null);\n return;\n }\n\n txtInput.Text = \"\";\n AppendBubble(message, isUser: true, isSystem: false);\n conversationHistory.Add(new ChatMessage { Role = \"user\", Content = message });\n\n isGenerating = true;\n btnSend.Text = \"停止\";\n btnSend.BackColor = Color.FromArgb(209, 52, 56);\n txtInput.Enabled = false;\n btnClear.Enabled = false;\n btnSettings.Enabled = false;\n\n cts = new CancellationTokenSource();\n\n try\n {\n await RunAgentLoopAsync(cts.Token);\n }\n catch (OperationCanceledException)\n {\n AppendBubble(\"[已终止]: 用户中止了 AI 响应。\", isUser: false, isSystem: true, isError: false);\n }\n catch (Exception ex)\n {\n AppendBubble($\"[AI 调用出错]:\\n{ex.ToString()}\", isUser: false, isSystem: true, isError: true);\n }\n finally\n {\n isGenerating = false;\n btnSend.Text = \"发送\";\n btnSend.BackColor = Color.FromArgb(0, 120, 212);\n \n txtInput.Enabled = true;\n btnClear.Enabled = true;\n btnSettings.Enabled = true;\n\n cts?.Dispose();\n cts = null;\n }\n }\n\n private async Task RunAgentLoopAsync(CancellationToken token)\n {\n int maxIterations = 5;\n int currentIteration = 0;\n\n var tools = new List<ChatTool>\n {\n new ChatTool\n {\n Function = new ToolFunction\n {\n Name = \"execute_csharp_code\",\n Description = \"在幻灯片中动态编译并执行C#代码来操作系统。代码将被放置在 `public static void Run(PowerPoint.Application Application)` 方法体中运行。你可以使用任何 PowerPoint API 进行操作。\",\n Parameters = new ToolParameters\n {\n Properties = new Dictionary<string, object>\n {\n {\n \"code\", new Dictionary<string, string>\n {\n { \"type\", \"string\" },\n { \"description\", \"要执行的 C# 语句。必须使用合法的 C# 语法,不用声明 class 或 method,只写方法体内部的执行代码。\" }\n }\n }\n },\n Required = new List<string> { \"code\" }\n }\n }\n }\n };\n\n while (currentIteration < maxIterations)\n {\n token.ThrowIfCancellationRequested();\n currentIteration++;\n\n var request = new ChatRequest\n {\n Model = currentConfig.Model,\n Messages = conversationHistory,\n Tools = tools,\n ToolChoice = \"auto\"\n };\n\n string requestJson = JsonConvert.SerializeObject(request);\n var content = new StringContent(requestJson, Encoding.UTF8, \"application/json\");\n\n httpClient.DefaultRequestHeaders.Clear();\n httpClient.DefaultRequestHeaders.Add(\"Authorization\", $\"Bearer {currentConfig.ApiKey}\");\n\n string url = currentConfig.ApiUrl.TrimEnd('/') + \"/chat/completions\";\n \n HttpResponseMessage response;\n try\n {\n response = await httpClient.PostAsync(url, content, token);\n }\n catch (OperationCanceledException)\n {\n throw;\n }\n catch (Exception ex)\n {\n throw new Exception($\"无法连接到 AI 服务,网络连接异常: {ex.Message}\\n请求地址: {url}\\n\\n详细信息:\\n{ex.ToString()}\");\n }\n\n token.ThrowIfCancellationRequested();\n string responseJson = await response.Content.ReadAsStringAsync();\n if (!response.IsSuccessStatusCode)\n {\n string errorDetails = \"\";\n try\n {\n var errRes = JsonConvert.DeserializeObject<ChatResponse>(responseJson);\n errorDetails = errRes?.Error?.Message;\n }\n catch {}\n if (string.IsNullOrEmpty(errorDetails))\n {\n errorDetails = responseJson;\n }\n throw new Exception($\"API 请求失败 (HTTP {response.StatusCode} - {response.ReasonPhrase}):\\n{errorDetails}\");\n }\n\n var chatRes = JsonConvert.DeserializeObject<ChatResponse>(responseJson);\n if (chatRes == null || chatRes.Choices == null || chatRes.Choices.Count == 0)\n {\n throw new Exception(\"API 返回了空响应,无法处理。\");\n }\n\n var choiceMessage = chatRes.Choices[0].Message;\n conversationHistory.Add(choiceMessage);\n\n if (!string.IsNullOrEmpty(choiceMessage.Content))\n {\n AppendBubble(choiceMessage.Content, isUser: false, isSystem: false);\n }\n\n if (choiceMessage.ToolCalls != null && choiceMessage.ToolCalls.Count > 0)\n {\n token.ThrowIfCancellationRequested();\n foreach (var toolCall in choiceMessage.ToolCalls)\n {\n if (toolCall.Function.Name == \"execute_csharp_code\")\n {\n string code = \"\";\n try\n {\n var args = JsonConvert.DeserializeObject<Dictionary<string, string>>(toolCall.Function.Arguments);\n if (args != null && args.TryGetValue(\"code\", out string argCode))\n {\n code = argCode;\n }\n }\n catch (Exception ex)\n {\n string toolErr = $\"解析参数失败: {ex.Message}\";\n conversationHistory.Add(new ChatMessage\n {\n Role = \"tool\",\n Name = \"execute_csharp_code\",\n Content = toolErr,\n ToolCallId = toolCall.Id\n });\n AppendBubble($\"[工具调用解析失败]\\n{toolErr}\", isUser: false, isSystem: true, isError: true);\n continue;\n }\n\n AppendBubble($\"[AI 请求执行 C# 代码]:\\n{code}\", isUser: false, isSystem: true);\n\n string runResult = \"\";\n try\n {\n runResult = CSharpExecutor.Execute(code, Globals.ThisAddIn.Application);\n }\n catch (Exception ex)\n {\n runResult = $\"Critical Runtime Error: {ex.Message}\";\n }\n\n conversationHistory.Add(new ChatMessage\n {\n Role = \"tool\",\n Name = \"execute_csharp_code\",\n Content = runResult,\n ToolCallId = toolCall.Id\n });\n\n if (runResult == \"Success\")\n {\n AppendBubble(\"[执行结果]: 成功!\", isUser: false, isSystem: true);\n }\n else\n {\n AppendBubble($\"[执行结果]: 失败!\\n{runResult}\", isUser: false, isSystem: true, isError: true);\n }\n }\n }\n continue;\n }\n\n break;\n }\n }\n\n public static Bitmap CreateIcon(string type, int size, Color color)\n {\n Bitmap bmp = new Bitmap(size, size);\n using (Graphics g = Graphics.FromImage(bmp))\n {\n g.SmoothingMode = SmoothingMode.AntiAlias;\n g.Clear(Color.Transparent);\n using (Pen pen = new Pen(color, 1.8f))\n using (Brush brush = new SolidBrush(color))\n {\n if (type == \"settings\")\n {\n int r1 = size / 2 - 1;\n int r2 = size / 2 - 4;\n int cx = size / 2;\n int cy = size / 2;\n g.DrawEllipse(pen, cx - r2, cy - r2, r2 * 2, r2 * 2);\n \n for (int i = 0; i < 8; i++)\n {\n double angle = i * Math.PI / 4;\n float x1 = (float)(cx + r2 * Math.Cos(angle));\n float y1 = (float)(cy + r2 * Math.Sin(angle));\n float x2 = (float)(cx + r1 * Math.Cos(angle));\n float y2 = (float)(cy + r1 * Math.Sin(angle));\n g.DrawLine(pen, x1, y1, x2, y2);\n }\n }\n else if (type == \"trash\")\n {\n g.DrawRectangle(pen, 4, 5, size - 9, size - 8);\n g.DrawLine(pen, 2, 3, size - 3, 3);\n g.DrawLine(pen, 6, 1, size - 7, 1);\n g.DrawLine(pen, 7, 7, 7, size - 5);\n g.DrawLine(pen, size / 2, 7, size / 2, size - 5);\n g.DrawLine(pen, size - 8, 7, size - 8, size - 5);\n }\n }\n }\n return bmp;\n }\n }\n\n // --- Settings Dialog Form ---\n public class AISettingsForm : Form\n {\n private ComboBox cmbPresets;\n private Button btnAddPreset;\n private Button btnDeletePreset;\n private TextBox txtPresetName;\n private TextBox txtApiUrl;\n private TextBox txtApiKey;\n private CheckBox chkShowKey;\n private TextBox txtModel;\n private TextBox txtSystemPrompt;\n private Button btnSave;\n private Button btnCancel;\n private Button btnRestoreDefault;\n private AIConfig config;\n private List<AIPreset> tempPresets;\n private string activePresetName;\n private AIPreset currentEditingPreset = null;\n\n public AISettingsForm(AIConfig currentConfig)\n {\n this.config = currentConfig;\n this.tempPresets = new List<AIPreset>();\n foreach (var p in currentConfig.Presets)\n tempPresets.Add(new AIPreset { Name = p.Name, ApiUrl = p.ApiUrl, ApiKey = p.ApiKey, Model = p.Model, SystemPrompt = p.SystemPrompt });\n this.activePresetName = currentConfig.CurrentPresetName;\n InitializeComponent();\n LoadPresetsToCombo();\n }\n\n private void InitializeComponent()\n {\n this.Text = \"AI 助手设置\";\n this.Size = new Size(620, 580);\n this.FormBorderStyle = FormBorderStyle.FixedDialog;\n this.MaximizeBox = false;\n this.MinimizeBox = false;\n this.StartPosition = FormStartPosition.CenterParent;\n this.Font = new Font(\"Microsoft YaHei\", 9F);\n this.BackColor = Color.White;\n\n Label lblSelectPreset = new Label { Text = \"选择预设:\", Location = new Point(15, 20), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n cmbPresets = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Location = new Point(100, 20), Width = 280, Height = 24 };\n\n btnAddPreset = new Button { Text = \"新增\", Location = new Point(390, 18), Width = 90, Height = 28, BackColor = Color.FromArgb(240, 240, 240), FlatStyle = FlatStyle.Flat };\n btnAddPreset.FlatAppearance.BorderColor = Color.LightGray;\n btnDeletePreset = new Button { Text = \"删除\", Location = new Point(490, 18), Width = 90, Height = 28, BackColor = Color.FromArgb(240, 240, 240), FlatStyle = FlatStyle.Flat };\n btnDeletePreset.FlatAppearance.BorderColor = Color.LightGray;\n\n Label lblPresetName = new Label { Text = \"预设名称:\", Location = new Point(15, 60), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtPresetName = new TextBox { Location = new Point(100, 60), Width = 480 };\n txtPresetName.TextChanged += TxtPresetName_TextChanged;\n\n Label lblUrl = new Label { Text = \"API 地址:\", Location = new Point(15, 100), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtApiUrl = new TextBox { Location = new Point(100, 100), Width = 480 };\n\n Label lblKey = new Label { Text = \"API Key:\", Location = new Point(15, 140), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtApiKey = new TextBox { Location = new Point(100, 140), Width = 400, PasswordChar = '*' };\n chkShowKey = new CheckBox { Text = \"显示\", Location = new Point(510, 140), Width = 70, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n chkShowKey.CheckedChanged += (s, e) => { txtApiKey.PasswordChar = chkShowKey.Checked ? '\\0' : '*'; };\n\n Label lblModel = new Label { Text = \"模型名称:\", Location = new Point(15, 180), Width = 80, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtModel = new TextBox { Location = new Point(100, 180), Width = 480 };\n\n Label lblPrompt = new Label { Text = \"系统提示词:\", Location = new Point(15, 220), Width = 85, Height = 22, TextAlign = ContentAlignment.MiddleLeft };\n txtSystemPrompt = new TextBox { Location = new Point(100, 220), Width = 480, Height = 240, Multiline = true, ScrollBars = ScrollBars.Vertical, Font = new Font(\"Microsoft YaHei\", 9F) };\n\n btnRestoreDefault = new Button { Text = \"恢复默认提示词\", Location = new Point(100, 485), Width = 150, Height = 28, FlatStyle = FlatStyle.Flat };\n btnRestoreDefault.FlatAppearance.BorderColor = Color.LightGray;\n btnRestoreDefault.Click += (s, e) => { txtSystemPrompt.Text = AISidebarControl.GetDefaultSystemPrompt(); };\n\n btnSave = new Button { Text = \"保存\", DialogResult = DialogResult.OK, Location = new Point(380, 485), Width = 90, Height = 28, BackColor = Color.FromArgb(0, 120, 212), ForeColor = Color.White, FlatStyle = FlatStyle.Flat };\n btnSave.FlatAppearance.BorderSize = 0;\n btnCancel = new Button { Text = \"取消\", DialogResult = DialogResult.Cancel, Location = new Point(490, 485), Width = 90, Height = 28, FlatStyle = FlatStyle.Flat };\n btnCancel.FlatAppearance.BorderColor = Color.LightGray;\n\n this.Controls.AddRange(new Control[] {\n lblSelectPreset, cmbPresets, btnAddPreset, btnDeletePreset,\n lblPresetName, txtPresetName, lblUrl, txtApiUrl,\n lblKey, txtApiKey, chkShowKey, lblModel, txtModel,\n lblPrompt, txtSystemPrompt, btnRestoreDefault, btnSave, btnCancel\n });\n this.AcceptButton = btnSave;\n this.CancelButton = btnCancel;\n btnAddPreset.Click += BtnAddPreset_Click;\n btnDeletePreset.Click += BtnDeletePreset_Click;\n }\n\n private void LoadPresetsToCombo()\n {\n cmbPresets.SelectedIndexChanged -= CmbPresets_SelectedIndexChanged;\n cmbPresets.DataSource = null;\n cmbPresets.DataSource = tempPresets;\n cmbPresets.DisplayMember = \"Name\";\n int idx = tempPresets.FindIndex(p => p.Name == activePresetName);\n if (idx < 0 && tempPresets.Count > 0) idx = 0;\n cmbPresets.SelectedIndex = idx;\n if (idx >= 0) { LoadPresetToFields(tempPresets[idx]); activePresetName = tempPresets[idx].Name; }\n cmbPresets.SelectedIndexChanged += CmbPresets_SelectedIndexChanged;\n }\n\n private void SaveCurrentPresetEdits()\n {\n if (currentEditingPreset != null)\n {\n currentEditingPreset.Name = txtPresetName.Text.Trim();\n currentEditingPreset.ApiUrl = txtApiUrl.Text.Trim();\n currentEditingPreset.ApiKey = txtApiKey.Text.Trim();\n currentEditingPreset.Model = txtModel.Text.Trim();\n currentEditingPreset.SystemPrompt = txtSystemPrompt.Text;\n }\n }\n\n private void LoadPresetToFields(AIPreset preset)\n {\n currentEditingPreset = preset;\n if (preset != null)\n {\n txtPresetName.Text = preset.Name;\n txtApiUrl.Text = preset.ApiUrl;\n txtApiKey.Text = preset.ApiKey;\n txtModel.Text = preset.Model;\n txtSystemPrompt.Text = preset.SystemPrompt;\n }\n }\n\n private void CmbPresets_SelectedIndexChanged(object sender, EventArgs e)\n {\n SaveCurrentPresetEdits();\n if (cmbPresets.SelectedIndex >= 0)\n {\n var selected = cmbPresets.SelectedItem as AIPreset;\n LoadPresetToFields(selected);\n this.activePresetName = selected?.Name ?? \"\";\n }\n }\n\n private void TxtPresetName_TextChanged(object sender, EventArgs e)\n {\n if (currentEditingPreset != null && cmbPresets.SelectedItem == currentEditingPreset)\n {\n string newName = txtPresetName.Text.Trim();\n if (!string.IsNullOrEmpty(newName) && currentEditingPreset.Name != newName)\n {\n currentEditingPreset.Name = newName;\n int idx = cmbPresets.SelectedIndex;\n cmbPresets.SelectedIndexChanged -= CmbPresets_SelectedIndexChanged;\n cmbPresets.DataSource = null;\n cmbPresets.DataSource = tempPresets;\n cmbPresets.DisplayMember = \"Name\";\n cmbPresets.SelectedIndex = idx;\n cmbPresets.SelectedIndexChanged += CmbPresets_SelectedIndexChanged;\n }\n }\n }\n\n private void BtnAddPreset_Click(object sender, EventArgs e)\n {\n SaveCurrentPresetEdits();\n int count = 1;\n string newName = \"New Preset\";\n while (tempPresets.Exists(p => p.Name == $\"{newName} {count}\")) count++;\n string finalName = $\"{newName} {count}\";\n var newPreset = new AIPreset { Name = finalName, ApiUrl = \"https://api.openai.com/v1\", ApiKey = \"\", Model = \"gpt-4o\", SystemPrompt = AISidebarControl.GetDefaultSystemPrompt() };\n tempPresets.Add(newPreset);\n activePresetName = finalName;\n LoadPresetsToCombo();\n }\n\n private void BtnDeletePreset_Click(object sender, EventArgs e)\n {\n if (tempPresets.Count <= 1) { MessageBox.Show(\"必须保留至少一个预设!\", \"提示\", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }\n if (cmbPresets.SelectedItem is AIPreset selected)\n {\n if (MessageBox.Show($\"确定删除预设 '{selected.Name}' 吗?\", \"确认删除\", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)\n {\n tempPresets.Remove(selected);\n currentEditingPreset = null;\n activePresetName = tempPresets[0].Name;\n LoadPresetsToCombo();\n }\n }\n }\n\n public void UpdateConfig(AIConfig targetConfig)\n {\n SaveCurrentPresetEdits();\n targetConfig.Presets = this.tempPresets;\n targetConfig.CurrentPresetName = this.activePresetName;\n }\n }\n\n // --- Data Models for API ---\n public class AIPreset\n {\n public string Name { get; set; } = \"Default\";\n public string ApiUrl { get; set; } = \"https://api.openai.com/v1\";\n public string ApiKey { get; set; } = \"\";\n public string Model { get; set; } = \"gpt-4o\";\n public string SystemPrompt { get; set; } = \"\";\n public override string ToString() => Name;\n }\n\n public class AIConfig\n {\n public List<AIPreset> Presets { get; set; } = new List<AIPreset>();\n public string CurrentPresetName { get; set; } = \"\";\n\n [JsonIgnore]\n public AIPreset CurrentPreset\n {\n get\n {\n if (Presets == null || Presets.Count == 0)\n {\n Presets = new List<AIPreset> { new AIPreset { Name = \"Default\" } };\n CurrentPresetName = \"Default\";\n }\n var active = Presets.Find(p => p.Name == CurrentPresetName);\n if (active == null) { active = Presets[0]; CurrentPresetName = active.Name; }\n return active;\n }\n }\n\n [JsonIgnore] public string ApiUrl { get => CurrentPreset.ApiUrl; set => CurrentPreset.ApiUrl = value; }\n [JsonIgnore] public string ApiKey { get => CurrentPreset.ApiKey; set => CurrentPreset.ApiKey = value; }\n [JsonIgnore] public string Model { get => CurrentPreset.Model; set => CurrentPreset.Model = value; }\n [JsonIgnore] public string SystemPrompt { get => CurrentPreset.SystemPrompt; set => CurrentPreset.SystemPrompt = value; }\n }\n\n public class ChatRequest\n {\n [JsonProperty(\"model\")]\n public string Model { get; set; }\n [JsonProperty(\"messages\")]\n public List<ChatMessage> Messages { get; set; }\n [JsonProperty(\"tools\", NullValueHandling = NullValueHandling.Ignore)]\n public List<ChatTool> Tools { get; set; }\n [JsonProperty(\"tool_choice\", NullValueHandling = NullValueHandling.Ignore)]\n public string ToolChoice { get; set; }\n }\n\n public class ChatMessage\n {\n [JsonProperty(\"role\")]\n public string Role { get; set; }\n [JsonProperty(\"content\", NullValueHandling = NullValueHandling.Ignore)]\n public string Content { get; set; }\n [JsonProperty(\"reasoning_content\", NullValueHandling = NullValueHandling.Ignore)]\n public string ReasoningContent { get; set; }\n [JsonProperty(\"tool_calls\", NullValueHandling = NullValueHandling.Ignore)]\n public List<ToolCall> ToolCalls { get; set; }\n [JsonProperty(\"tool_call_id\", NullValueHandling = NullValueHandling.Ignore)]\n public string ToolCallId { get; set; }\n [JsonProperty(\"name\", NullValueHandling = NullValueHandling.Ignore)]\n public string Name { get; set; }\n }\n\n public class ChatTool\n {\n [JsonProperty(\"type\")]\n public string Type { get; set; } = \"function\";\n [JsonProperty(\"function\")]\n public ToolFunction Function { get; set; }\n }\n\n public class ToolFunction\n {\n [JsonProperty(\"name\")]\n public string Name { get; set; }\n [JsonProperty(\"description\")]\n public string Description { get; set; }\n [JsonProperty(\"parameters\")]\n public ToolParameters Parameters { get; set; }\n }\n\n public class ToolParameters\n {\n [JsonProperty(\"type\")]\n public string Type { get; set; } = \"object\";\n [JsonProperty(\"properties\")]\n public Dictionary<string, object> Properties { get; set; }\n [JsonProperty(\"required\")]\n public List<string> Required { get; set; }\n }\n\n public class ToolCall\n {\n [JsonProperty(\"id\")]\n public string Id { get; set; }\n [JsonProperty(\"type\")]\n public string Type { get; set; }\n [JsonProperty(\"function\")]\n public ToolCallFunction Function { get; set; }\n }\n\n public class ToolCallFunction\n {\n [JsonProperty(\"name\")]\n public string Name { get; set; }\n [JsonProperty(\"arguments\")]\n public string Arguments { get; set; }\n }\n\n public class ChatResponse\n {\n [JsonProperty(\"choices\")]\n public List<ChatChoice> Choices { get; set; }\n [JsonProperty(\"error\")]\n public ApiError Error { get; set; }\n }\n\n public class ChatChoice\n {\n [JsonProperty(\"message\")]\n public ChatMessage Message { get; set; }\n }\n\n public class ApiError\n {\n [JsonProperty(\"message\")]\n public string Message { get; set; }\n }\n\n // --- Custom UI Controls ---\n public class ChatRowPanel : Panel\n {\n [System.Runtime.InteropServices.DllImport(\"user32.dll\")]\n private static extern bool HideCaret(IntPtr hWnd);\n\n private Panel bubblePanel;\n private RichTextBox rtbText;\n private bool isUser;\n private bool isSystem;\n private bool isError;\n\n // Collapsible properties\n private bool isCollapsible = false;\n private bool isExpanded = false;\n private string headerText = \"\";\n private string bodyText = \"\";\n private Panel headerPanel;\n private Button btnToggle;\n\n public ChatRowPanel(string text, bool isUser, bool isSystem, bool isError, int width)\n {\n this.isUser = isUser;\n this.isSystem = isSystem;\n this.isError = isError;\n this.Width = width;\n this.BackColor = Color.Transparent;\n\n // Check if it is a collapsible code block or output log\n if (isSystem && (text.StartsWith(\"[AI 请求执行 C# 代码]:\") || text.StartsWith(\"[执行结果]:\")))\n {\n int firstNewLine = text.IndexOf('\\n');\n if (firstNewLine >= 0)\n {\n headerText = text.Substring(0, firstNewLine).TrimEnd(':');\n bodyText = text.Substring(firstNewLine + 1);\n isCollapsible = !string.IsNullOrWhiteSpace(bodyText);\n }\n else\n {\n headerText = text;\n bodyText = \"\";\n isCollapsible = false;\n }\n isExpanded = false; // Collapsed by default\n }\n\n bubblePanel = new Panel\n {\n BackColor = Color.Transparent,\n Padding = new Padding(10, 8, 10, 8),\n AutoSize = false\n };\n\n rtbText = new RichTextBox\n {\n BorderStyle = BorderStyle.None,\n ScrollBars = RichTextBoxScrollBars.None,\n ReadOnly = true,\n Multiline = true,\n WordWrap = true,\n BackColor = Color.White,\n Font = new Font(\"Microsoft YaHei\", 9F)\n };\n\n rtbText.GotFocus += (s, e) => { HideCaret(rtbText.Handle); };\n rtbText.MouseDown += (s, e) => { HideCaret(rtbText.Handle); };\n\n if (isCollapsible)\n {\n headerPanel = new Panel\n {\n Height = 24,\n BackColor = Color.Transparent,\n Location = new Point(10, 6)\n };\n\n Label lblHeader = new Label\n {\n Text = headerText,\n Font = new Font(\"Microsoft YaHei\", 9F, FontStyle.Bold),\n ForeColor = isError ? Color.FromArgb(180, 40, 40) : Color.FromArgb(80, 80, 80),\n Location = new Point(0, 3),\n AutoSize = true\n };\n headerPanel.Controls.Add(lblHeader);\n\n btnToggle = new Button\n {\n Text = \"▶ 展开\",\n Font = new Font(\"Microsoft YaHei\", 8F),\n FlatStyle = FlatStyle.Flat,\n BackColor = Color.Transparent,\n ForeColor = Color.FromArgb(0, 120, 212),\n Location = new Point(lblHeader.Right + 6, 1),\n Width = 48,\n Height = 22,\n Cursor = Cursors.Hand\n };\n btnToggle.FlatAppearance.BorderSize = 0;\n btnToggle.FlatAppearance.MouseOverBackColor = Color.FromArgb(230, 230, 230);\n btnToggle.Click += BtnToggle_Click;\n headerPanel.Controls.Add(btnToggle);\n\n lblHeader.SizeChanged += (s, e) => { btnToggle.Left = lblHeader.Right + 6; };\n\n bubblePanel.Controls.Add(headerPanel);\n\n rtbText.Text = bodyText;\n rtbText.Font = new Font(\"Consolas\", 8.5F);\n rtbText.ForeColor = Color.FromArgb(60, 60, 60);\n rtbText.Location = new Point(10, 32);\n rtbText.Visible = false;\n\n bubblePanel.Controls.Add(rtbText);\n }\n else\n {\n FormatMarkdown(rtbText, text, isUser);\n rtbText.Location = new Point(10, 8);\n bubblePanel.Controls.Add(rtbText);\n }\n\n bubblePanel.Paint += BubblePanel_Paint;\n this.Controls.Add(bubblePanel);\n UpdatePosition(width);\n }\n\n private void BtnToggle_Click(object sender, EventArgs e)\n {\n isExpanded = !isExpanded;\n rtbText.Visible = isExpanded;\n btnToggle.Text = isExpanded ? \"▼ 收起\" : \"▶ 展开\";\n \n UpdatePosition(this.Width);\n \n if (this.Parent is FlowLayoutPanel flp)\n {\n flp.PerformLayout();\n }\n }\n\n private void BubblePanel_Paint(object sender, PaintEventArgs e)\n {\n e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;\n Color backColor;\n if (isError) backColor = Color.FromArgb(253, 231, 233);\n else if (isSystem) backColor = Color.FromArgb(240, 240, 240);\n else if (isUser) backColor = Color.FromArgb(0, 120, 212);\n else backColor = Color.White;\n\n using (GraphicsPath path = GetRoundedPath(new Rectangle(0, 0, bubblePanel.Width - 1, bubblePanel.Height - 1), 8))\n using (SolidBrush brush = new SolidBrush(backColor))\n {\n e.Graphics.FillPath(brush, path);\n }\n\n if (!isUser && !isError)\n {\n using (GraphicsPath path = GetRoundedPath(new Rectangle(0, 0, bubblePanel.Width - 1, bubblePanel.Height - 1), 8))\n using (Pen pen = new Pen(Color.FromArgb(220, 220, 220), 1))\n {\n e.Graphics.DrawPath(pen, path);\n }\n }\n }\n\n private GraphicsPath GetRoundedPath(Rectangle rect, int radius)\n {\n GraphicsPath path = new GraphicsPath();\n int d = radius * 2;\n path.AddArc(rect.X, rect.Y, d, d, 180, 90);\n path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90);\n path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90);\n path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90);\n path.CloseFigure();\n return path;\n }\n\n public void UpdateWidth(int width)\n {\n this.Width = width;\n UpdatePosition(width);\n }\n\n private void UpdatePosition(int width)\n {\n int maxBubbleWidth = Convert.ToInt32(width * 0.85);\n int innerPaddingWidth = 20;\n\n int textWidth = maxBubbleWidth - innerPaddingWidth;\n if (textWidth < 50) textWidth = 50;\n\n // 1. Calculate the final bubble and RichTextBox widths\n Size measuredSize = TextRenderer.MeasureText(rtbText.Text, rtbText.Font, new Size(textWidth - 20, 0), TextFormatFlags.WordBreak);\n \n int finalBubbleWidth;\n if (isCollapsible)\n {\n finalBubbleWidth = maxBubbleWidth;\n }\n else\n {\n finalBubbleWidth = Math.Min(measuredSize.Width + innerPaddingWidth + 10, maxBubbleWidth);\n }\n\n int finalRtbWidth = finalBubbleWidth - innerPaddingWidth;\n if (finalRtbWidth < 50) finalRtbWidth = 50;\n\n rtbText.Width = finalRtbWidth;\n\n // Force handle creation so GetPositionFromCharIndex is completely accurate\n var handle = rtbText.Handle;\n\n // 2. Measure precise text wrapping height natively\n int calculatedHeight = 0;\n if (!string.IsNullOrEmpty(rtbText.Text))\n {\n Point pos = rtbText.GetPositionFromCharIndex(rtbText.Text.Length - 1);\n \n // Fallback if handle isn't fully ready or pos.Y returns 0 unexpectedly for multi-line text\n if (pos.Y == 0 && rtbText.Text.Contains(\"\\n\"))\n {\n int lineCount = rtbText.Lines.Length;\n int listBonus = 0;\n foreach (var line in rtbText.Lines)\n {\n if (line.TrimStart().StartsWith(\"- \") || line.TrimStart().StartsWith(\"* \"))\n {\n listBonus += 15;\n }\n }\n int boldAndFontPadding = 24 + (lineCount * 4) + listBonus;\n calculatedHeight = measuredSize.Height + boldAndFontPadding;\n }\n else\n {\n calculatedHeight = pos.Y + rtbText.Font.Height + 16;\n }\n }\n\n // 3. Set control heights and layout bounds\n if (isCollapsible)\n {\n headerPanel.Width = finalRtbWidth;\n if (isExpanded)\n {\n rtbText.Height = calculatedHeight;\n bubblePanel.Width = maxBubbleWidth;\n bubblePanel.Height = rtbText.Bottom + 8;\n }\n else\n {\n bubblePanel.Width = maxBubbleWidth;\n bubblePanel.Height = headerPanel.Bottom + 6;\n }\n }\n else\n {\n rtbText.Height = calculatedHeight;\n bubblePanel.Width = finalBubbleWidth;\n bubblePanel.Height = rtbText.Height + 16;\n }\n\n Color bubbleColor;\n if (isError) bubbleColor = Color.FromArgb(253, 231, 233);\n else if (isSystem) bubbleColor = Color.FromArgb(240, 240, 240);\n else if (isUser) bubbleColor = Color.FromArgb(0, 120, 212);\n else bubbleColor = Color.White;\n\n rtbText.BackColor = bubbleColor;\n rtbText.ForeColor = isUser ? Color.White : Color.FromArgb(33, 33, 33);\n\n this.Height = bubblePanel.Height + 10;\n\n if (isUser)\n {\n bubblePanel.Left = width - bubblePanel.Width - 5;\n }\n else\n {\n bubblePanel.Left = 5;\n }\n bubblePanel.Top = 5;\n \n bubblePanel.Invalidate();\n }\n\n private static void FormatMarkdown(RichTextBox rtb, string markdown, bool isUser)\n {\n rtb.Text = \"\";\n rtb.SelectionBullet = false;\n rtb.SelectionIndent = 0;\n\n string[] lines = markdown.Split('\\n');\n for (int l = 0; l < lines.Length; l++)\n {\n string line = lines[l];\n \n if (line.TrimStart().StartsWith(\"- \") || line.TrimStart().StartsWith(\"* \"))\n {\n int bulletIndex = line.IndexOf('-');\n if (bulletIndex < 0) bulletIndex = line.IndexOf('*');\n line = line.Substring(bulletIndex + 2);\n rtb.SelectionBullet = true;\n rtb.SelectionIndent = 12;\n }\n else\n {\n rtb.SelectionBullet = false;\n rtb.SelectionIndent = 0;\n }\n\n int i = 0;\n while (i < line.Length)\n {\n if (i + 1 < line.Length && line[i] == '*' && line[i + 1] == '*')\n {\n int next = line.IndexOf(\"**\", i + 2);\n if (next >= 0)\n {\n string boldText = line.Substring(i + 2, next - (i + 2));\n rtb.SelectionFont = new Font(rtb.Font, FontStyle.Bold);\n rtb.AppendText(boldText);\n i = next + 2;\n }\n else\n {\n rtb.SelectionFont = new Font(rtb.Font, FontStyle.Regular);\n rtb.AppendText(\"**\");\n i += 2;\n }\n }\n else if (line[i] == '`')\n {\n int next = line.IndexOf('`', i + 1);\n if (next >= 0)\n {\n string codeText = line.Substring(i + 1, next - (i + 1));\n \n rtb.SelectionFont = new Font(\"Consolas\", rtb.Font.Size);\n rtb.SelectionBackColor = isUser ? Color.FromArgb(0, 60, 120) : Color.FromArgb(228, 229, 230);\n rtb.SelectionColor = isUser ? Color.FromArgb(200, 230, 255) : Color.FromArgb(190, 50, 50);\n\n rtb.AppendText(codeText);\n \n rtb.SelectionBackColor = rtb.BackColor;\n rtb.SelectionColor = rtb.ForeColor;\n \n i = next + 1;\n }\n else\n {\n rtb.SelectionFont = new Font(rtb.Font, FontStyle.Regular);\n rtb.AppendText(\"`\");\n i++;\n }\n }\n else\n {\n rtb.SelectionFont = new Font(rtb.Font, FontStyle.Regular);\n rtb.SelectionColor = isUser ? Color.White : Color.FromArgb(33, 33, 33);\n rtb.AppendText(line[i].ToString());\n i++;\n }\n }\n\n if (l < lines.Length - 1)\n {\n rtb.AppendText(Environment.NewLine);\n }\n }\n }\n }\n}\n"}}}}