1175 lines
41 KiB
C#
1175 lines
41 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Drawing;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using System.Web.Script.Serialization;
|
|
using System.Runtime.InteropServices;
|
|
using System.Windows.Forms;
|
|
|
|
namespace GesisOpenCodeInstaller
|
|
{
|
|
internal static class Program
|
|
{
|
|
[STAThread]
|
|
private static void Main()
|
|
{
|
|
TryEnableDpiAwareness();
|
|
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
|
|
Application.EnableVisualStyles();
|
|
Application.SetCompatibleTextRenderingDefault(false);
|
|
Application.Run(new InstallerForm());
|
|
}
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool SetProcessDPIAware();
|
|
|
|
private static void TryEnableDpiAwareness()
|
|
{
|
|
try
|
|
{
|
|
SetProcessDPIAware();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class InstallerForm : Form
|
|
{
|
|
private const string Repo = "anomalyco/opencode";
|
|
private const string ReleaseApiUrl = "https://api.github.com/repos/" + Repo + "/releases/latest";
|
|
private const string WindowsAssetName = "opencode-desktop-win-x64.exe";
|
|
private const string BaseUrl = "https://ai-openwebui.gesis.org/api";
|
|
private const int DesktopInstallerWaitMilliseconds = 15000;
|
|
private const int DownloadTimeoutMilliseconds = 60000;
|
|
|
|
private readonly TextBox apiKeyBox;
|
|
private readonly TextBox modelFilterBox;
|
|
private readonly TableLayoutPanel modelGridPanel;
|
|
private readonly Label modelIntroLabel;
|
|
private readonly Button checkApiKeyButton;
|
|
private readonly Button backButton;
|
|
private readonly Button installButton;
|
|
private readonly Button selectAllButton;
|
|
private readonly Button selectNoneButton;
|
|
private readonly ProgressBar progressBar;
|
|
private readonly Label statusLabel;
|
|
private readonly CheckBox skipDesktopCheckBox;
|
|
private readonly List<ModelOption> availableModels = new List<ModelOption>();
|
|
|
|
public InstallerForm()
|
|
{
|
|
Text = "GESIS OpenCode Installer";
|
|
StartPosition = FormStartPosition.CenterScreen;
|
|
FormBorderStyle = FormBorderStyle.Sizable;
|
|
MaximizeBox = true;
|
|
MinimizeBox = true;
|
|
ClientSize = new Size(760, 560);
|
|
MinimumSize = new Size(620, 500);
|
|
Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point);
|
|
BackColor = Color.White;
|
|
AutoScaleMode = AutoScaleMode.Dpi;
|
|
AutoScroll = false;
|
|
|
|
var logoBox = new PictureBox
|
|
{
|
|
Margin = new Padding(0, 0, 0, 18),
|
|
Size = new Size(330, 92),
|
|
SizeMode = PictureBoxSizeMode.Zoom,
|
|
Image = LoadEmbeddedImage("GesisLogo")
|
|
};
|
|
|
|
var title = new Label
|
|
{
|
|
Text = "Install OpenCode Desktop for GESIS",
|
|
Font = new Font(Font.FontFamily, 15F, FontStyle.Bold),
|
|
AutoSize = true,
|
|
MaximumSize = new Size(680, 0),
|
|
Margin = new Padding(0, 0, 0, 8)
|
|
};
|
|
|
|
var description = new Label
|
|
{
|
|
Text = "This installer checks your personal API key, lets you choose the GESIS AI Server models to show in OpenCode, and downloads the latest OpenCode Desktop for Windows.",
|
|
AutoSize = true,
|
|
MaximumSize = new Size(680, 0),
|
|
Margin = new Padding(0, 0, 0, 22)
|
|
};
|
|
|
|
var apiKeyLabel = new Label
|
|
{
|
|
Text = "Personal GESIS API key",
|
|
AutoSize = true,
|
|
Margin = new Padding(0, 0, 0, 6)
|
|
};
|
|
|
|
apiKeyBox = new TextBox
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
Margin = new Padding(0, 0, 0, 14),
|
|
MinimumSize = new Size(420, 30),
|
|
UseSystemPasswordChar = true
|
|
};
|
|
|
|
checkApiKeyButton = new Button
|
|
{
|
|
Text = "Check API key",
|
|
AutoSize = true,
|
|
MinimumSize = new Size(150, 40),
|
|
Padding = new Padding(12, 4, 12, 4),
|
|
Margin = new Padding(0, 0, 0, 18)
|
|
};
|
|
checkApiKeyButton.Click += CheckApiKeyButton_Click;
|
|
|
|
modelIntroLabel = new Label
|
|
{
|
|
Text = "These are all models currently available to your API key on the GESIS AI Server. Select the models you want to use in OpenCode.",
|
|
AutoSize = true,
|
|
MaximumSize = new Size(680, 0),
|
|
Margin = new Padding(0, 0, 0, 10),
|
|
Visible = false
|
|
};
|
|
|
|
modelFilterBox = new TextBox
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
Margin = new Padding(0, 0, 0, 10),
|
|
MinimumSize = new Size(420, 30),
|
|
Visible = false
|
|
};
|
|
modelFilterBox.TextChanged += ModelFilterBox_TextChanged;
|
|
|
|
modelGridPanel = new TableLayoutPanel
|
|
{
|
|
AutoSize = true,
|
|
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
|
BackColor = Color.White,
|
|
ColumnCount = 1,
|
|
Dock = DockStyle.Fill,
|
|
Margin = new Padding(0, 0, 0, 10),
|
|
Visible = false
|
|
};
|
|
|
|
selectAllButton = new Button
|
|
{
|
|
Text = "Select all",
|
|
AutoSize = true,
|
|
MinimumSize = new Size(110, 36),
|
|
Padding = new Padding(10, 3, 10, 3),
|
|
Visible = false
|
|
};
|
|
selectAllButton.Click += SelectAllButton_Click;
|
|
|
|
selectNoneButton = new Button
|
|
{
|
|
Text = "Select none",
|
|
AutoSize = true,
|
|
MinimumSize = new Size(110, 36),
|
|
Padding = new Padding(10, 3, 10, 3),
|
|
Visible = false
|
|
};
|
|
selectNoneButton.Click += SelectNoneButton_Click;
|
|
|
|
skipDesktopCheckBox = new CheckBox
|
|
{
|
|
Text = "Only update the OpenCode configuration",
|
|
AutoSize = true,
|
|
Margin = new Padding(0, 0, 0, 18),
|
|
Visible = false
|
|
};
|
|
|
|
installButton = new Button
|
|
{
|
|
Text = "Install selected models",
|
|
AutoSize = true,
|
|
MinimumSize = new Size(190, 40),
|
|
Padding = new Padding(12, 4, 12, 4),
|
|
Anchor = AnchorStyles.Right,
|
|
Margin = new Padding(8, 0, 0, 18),
|
|
Visible = false
|
|
};
|
|
installButton.Click += InstallButton_Click;
|
|
|
|
backButton = new Button
|
|
{
|
|
Text = "Back",
|
|
AutoSize = true,
|
|
MinimumSize = new Size(100, 40),
|
|
Padding = new Padding(12, 4, 12, 4),
|
|
Margin = new Padding(8, 0, 0, 18),
|
|
Visible = false
|
|
};
|
|
backButton.Click += BackButton_Click;
|
|
|
|
progressBar = new ProgressBar
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
MinimumSize = new Size(420, 18),
|
|
Margin = new Padding(0, 0, 0, 12),
|
|
Style = ProgressBarStyle.Continuous
|
|
};
|
|
|
|
statusLabel = new Label
|
|
{
|
|
Text = "Ready.",
|
|
AutoSize = true,
|
|
MaximumSize = new Size(680, 0),
|
|
Margin = new Padding(0)
|
|
};
|
|
|
|
var icon = LoadEmbeddedIcon("GesisIcon");
|
|
if (icon != null)
|
|
{
|
|
Icon = icon;
|
|
}
|
|
|
|
var selectionButtonPanel = new FlowLayoutPanel
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
AutoSize = true,
|
|
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
|
FlowDirection = FlowDirection.LeftToRight,
|
|
WrapContents = true,
|
|
Margin = new Padding(0, 0, 0, 12)
|
|
};
|
|
selectionButtonPanel.Controls.Add(selectAllButton);
|
|
selectionButtonPanel.Controls.Add(selectNoneButton);
|
|
|
|
var primaryButtonPanel = new FlowLayoutPanel
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
AutoSize = true,
|
|
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
|
FlowDirection = FlowDirection.RightToLeft,
|
|
WrapContents = true
|
|
};
|
|
primaryButtonPanel.Controls.Add(installButton);
|
|
primaryButtonPanel.Controls.Add(backButton);
|
|
primaryButtonPanel.Controls.Add(checkApiKeyButton);
|
|
|
|
var footerLayout = new TableLayoutPanel
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
AutoSize = true,
|
|
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
|
BackColor = Color.White,
|
|
ColumnCount = 1,
|
|
RowCount = 4,
|
|
Padding = new Padding(32, 12, 32, 16)
|
|
};
|
|
footerLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
|
footerLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
|
footerLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
|
footerLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
|
footerLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
|
footerLayout.Controls.Add(skipDesktopCheckBox, 0, 0);
|
|
footerLayout.Controls.Add(primaryButtonPanel, 0, 1);
|
|
footerLayout.Controls.Add(progressBar, 0, 2);
|
|
footerLayout.Controls.Add(statusLabel, 0, 3);
|
|
|
|
var layout = new TableLayoutPanel
|
|
{
|
|
Dock = DockStyle.Top,
|
|
AutoSize = true,
|
|
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
|
BackColor = Color.White,
|
|
ColumnCount = 1,
|
|
Padding = new Padding(32),
|
|
RowCount = 10
|
|
};
|
|
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
|
for (var i = 0; i < layout.RowCount; i++)
|
|
{
|
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
|
}
|
|
|
|
layout.Controls.Add(logoBox, 0, 0);
|
|
layout.Controls.Add(title, 0, 1);
|
|
layout.Controls.Add(description, 0, 2);
|
|
layout.Controls.Add(apiKeyLabel, 0, 3);
|
|
layout.Controls.Add(apiKeyBox, 0, 4);
|
|
layout.Controls.Add(modelIntroLabel, 0, 5);
|
|
layout.Controls.Add(modelFilterBox, 0, 6);
|
|
layout.Controls.Add(selectionButtonPanel, 0, 7);
|
|
layout.Controls.Add(modelGridPanel, 0, 8);
|
|
|
|
var contentPanel = new Panel
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
AutoScroll = true,
|
|
BackColor = Color.White
|
|
};
|
|
contentPanel.Controls.Add(layout);
|
|
|
|
var rootLayout = new TableLayoutPanel
|
|
{
|
|
Dock = DockStyle.Fill,
|
|
BackColor = Color.White,
|
|
ColumnCount = 1,
|
|
RowCount = 2
|
|
};
|
|
rootLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
|
rootLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
|
rootLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
|
rootLayout.Controls.Add(contentPanel, 0, 0);
|
|
rootLayout.Controls.Add(footerLayout, 0, 1);
|
|
|
|
Controls.Add(rootLayout);
|
|
|
|
AcceptButton = checkApiKeyButton;
|
|
}
|
|
|
|
protected override void OnShown(EventArgs e)
|
|
{
|
|
base.OnShown(e);
|
|
if (WindowState == FormWindowState.Minimized)
|
|
{
|
|
WindowState = FormWindowState.Normal;
|
|
}
|
|
|
|
Show();
|
|
Activate();
|
|
BringToFront();
|
|
TopMost = true;
|
|
TopMost = false;
|
|
}
|
|
|
|
private static Image LoadEmbeddedImage(string resourceName)
|
|
{
|
|
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName);
|
|
if (stream == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
using (stream)
|
|
{
|
|
return Image.FromStream(stream);
|
|
}
|
|
}
|
|
|
|
private static Icon LoadEmbeddedIcon(string resourceName)
|
|
{
|
|
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName);
|
|
if (stream == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new Icon(stream);
|
|
}
|
|
|
|
private async void CheckApiKeyButton_Click(object sender, EventArgs e)
|
|
{
|
|
var apiKey = apiKeyBox.Text.Trim();
|
|
if (apiKey.Length == 0)
|
|
{
|
|
MessageBox.Show(this, "Please enter your personal GESIS API key.", "API key required", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
apiKeyBox.Focus();
|
|
return;
|
|
}
|
|
|
|
SetBusy(true);
|
|
|
|
try
|
|
{
|
|
var models = await Task.Run(new Func<List<ModelOption>>(() => ProbeAvailableModels(apiKey)));
|
|
if (models.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("No models are available for this API key.");
|
|
}
|
|
|
|
availableModels.Clear();
|
|
availableModels.AddRange(models);
|
|
foreach (var model in availableModels)
|
|
{
|
|
model.IsSelected = true;
|
|
}
|
|
|
|
WindowState = FormWindowState.Maximized;
|
|
ShowModelSelection();
|
|
RefreshModelList();
|
|
ResizeForModelGrid();
|
|
progressBar.Value = 100;
|
|
statusLabel.Text = "Found " + availableModels.Count + " available model(s). Choose which models should appear in OpenCode.";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
statusLabel.Text = "API key check failed.";
|
|
MessageBox.Show(this, ex.Message, "API key check failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
}
|
|
finally
|
|
{
|
|
SetBusy(false);
|
|
}
|
|
}
|
|
|
|
private async void InstallButton_Click(object sender, EventArgs e)
|
|
{
|
|
var apiKey = apiKeyBox.Text.Trim();
|
|
var selectedModels = GetSelectedModels();
|
|
|
|
if (selectedModels.Count == 0)
|
|
{
|
|
MessageBox.Show(this, "Please select at least one model to use in OpenCode.", "Model selection required", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
return;
|
|
}
|
|
|
|
SetBusy(true);
|
|
|
|
try
|
|
{
|
|
await Task.Run(new Action(() => WriteOpenCodeConfig(apiKey, selectedModels)));
|
|
|
|
if (!skipDesktopCheckBox.Checked)
|
|
{
|
|
await Task.Run(new Action(InstallOpenCodeDesktop));
|
|
}
|
|
|
|
progressBar.Value = 100;
|
|
statusLabel.Text = "Installation complete.";
|
|
MessageBox.Show(this, "OpenCode is configured for the GESIS AI Server. If the OpenCode Desktop installer is still open, please follow its prompts to finish.", "Done", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
statusLabel.Text = "Installation failed.";
|
|
MessageBox.Show(this, ex.Message, "Installation failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
}
|
|
finally
|
|
{
|
|
SetBusy(false);
|
|
}
|
|
}
|
|
|
|
private void BackButton_Click(object sender, EventArgs e)
|
|
{
|
|
ShowApiKeyScreen();
|
|
}
|
|
|
|
private void ModelFilterBox_TextChanged(object sender, EventArgs e)
|
|
{
|
|
SyncVisibleModelSelection();
|
|
RefreshModelList();
|
|
}
|
|
|
|
private void SelectAllButton_Click(object sender, EventArgs e)
|
|
{
|
|
SetVisibleModelSelection(true);
|
|
}
|
|
|
|
private void SelectNoneButton_Click(object sender, EventArgs e)
|
|
{
|
|
SetVisibleModelSelection(false);
|
|
}
|
|
|
|
private void ShowModelSelection()
|
|
{
|
|
checkApiKeyButton.Visible = false;
|
|
apiKeyBox.Enabled = false;
|
|
modelIntroLabel.Visible = true;
|
|
modelFilterBox.Visible = true;
|
|
modelGridPanel.Visible = true;
|
|
selectAllButton.Visible = true;
|
|
selectNoneButton.Visible = true;
|
|
skipDesktopCheckBox.Visible = true;
|
|
installButton.Visible = true;
|
|
backButton.Visible = true;
|
|
AcceptButton = installButton;
|
|
}
|
|
|
|
private void ShowApiKeyScreen()
|
|
{
|
|
WindowState = FormWindowState.Normal;
|
|
checkApiKeyButton.Visible = true;
|
|
apiKeyBox.Enabled = true;
|
|
modelIntroLabel.Visible = false;
|
|
modelFilterBox.Visible = false;
|
|
modelGridPanel.Visible = false;
|
|
selectAllButton.Visible = false;
|
|
selectNoneButton.Visible = false;
|
|
skipDesktopCheckBox.Visible = false;
|
|
installButton.Visible = false;
|
|
backButton.Visible = false;
|
|
progressBar.Value = 0;
|
|
statusLabel.Text = "Ready.";
|
|
AcceptButton = checkApiKeyButton;
|
|
}
|
|
|
|
private void InstallOpenCodeDesktop()
|
|
{
|
|
if (!Environment.Is64BitOperatingSystem)
|
|
{
|
|
throw new InvalidOperationException("This installer requires 64-bit Windows.");
|
|
}
|
|
|
|
SetStatus("Finding the latest OpenCode Desktop release...", 10);
|
|
var releaseJson = DownloadString(ReleaseApiUrl);
|
|
var serializer = new JavaScriptSerializer();
|
|
var release = (Dictionary<string, object>)serializer.DeserializeObject(releaseJson);
|
|
var tagName = release.ContainsKey("tag_name") ? Convert.ToString(release["tag_name"]) : "latest";
|
|
var assets = release["assets"] as object[];
|
|
Dictionary<string, object> selectedAsset = null;
|
|
|
|
if (assets != null)
|
|
{
|
|
foreach (var item in assets)
|
|
{
|
|
var asset = item as Dictionary<string, object>;
|
|
if (asset == null || !asset.ContainsKey("name"))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (String.Equals(Convert.ToString(asset["name"]), WindowsAssetName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
selectedAsset = asset;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (selectedAsset == null || !selectedAsset.ContainsKey("browser_download_url"))
|
|
{
|
|
throw new InvalidOperationException("Could not find the Windows desktop installer in the latest OpenCode release.");
|
|
}
|
|
|
|
var downloadUrl = Convert.ToString(selectedAsset["browser_download_url"]);
|
|
var downloadPath = Path.Combine(Path.GetTempPath(), WindowsAssetName);
|
|
var expectedSize = selectedAsset.ContainsKey("size") ? Convert.ToInt64(selectedAsset["size"]) : -1;
|
|
|
|
if (expectedSize > 0 && File.Exists(downloadPath) && new FileInfo(downloadPath).Length == expectedSize)
|
|
{
|
|
SetStatus("Using already downloaded OpenCode Desktop " + tagName + ".", 70);
|
|
}
|
|
else
|
|
{
|
|
SetStatus("Downloading OpenCode Desktop " + tagName + "...", 30);
|
|
DownloadFileWithProgress(downloadUrl, downloadPath, expectedSize, "OpenCode Desktop " + tagName, 30, 70);
|
|
}
|
|
|
|
SetStatus("Starting the OpenCode Desktop installer...", 75);
|
|
var process = Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = downloadPath,
|
|
UseShellExecute = true
|
|
});
|
|
|
|
if (process == null)
|
|
{
|
|
throw new InvalidOperationException("Could not start the OpenCode Desktop installer.");
|
|
}
|
|
|
|
SetStatus("Waiting briefly for the OpenCode Desktop installer...", 90);
|
|
if (process.WaitForExit(DesktopInstallerWaitMilliseconds))
|
|
{
|
|
if (process.ExitCode != 0)
|
|
{
|
|
throw new InvalidOperationException("The OpenCode Desktop installer ended with exit code " + process.ExitCode + ".");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
SetStatus("OpenCode Desktop installer is still running. Continuing with GESIS setup.", 95);
|
|
}
|
|
|
|
private void DownloadFileWithProgress(string url, string destinationPath, long expectedSize, string label, int startProgress, int endProgress)
|
|
{
|
|
var partialPath = destinationPath + ".download";
|
|
if (File.Exists(partialPath))
|
|
{
|
|
File.Delete(partialPath);
|
|
}
|
|
|
|
var request = (HttpWebRequest)WebRequest.Create(url);
|
|
request.Method = "GET";
|
|
request.Timeout = DownloadTimeoutMilliseconds;
|
|
request.ReadWriteTimeout = DownloadTimeoutMilliseconds;
|
|
request.UserAgent = "GESIS-OpenCode-Installer";
|
|
request.Accept = "application/octet-stream";
|
|
|
|
using (var response = (HttpWebResponse)request.GetResponse())
|
|
{
|
|
if (response.StatusCode != HttpStatusCode.OK)
|
|
{
|
|
throw new InvalidOperationException("Could not download OpenCode Desktop. The server returned " + response.StatusCode + ".");
|
|
}
|
|
|
|
var totalBytes = response.ContentLength > 0 ? response.ContentLength : expectedSize;
|
|
var downloadedBytes = 0L;
|
|
var buffer = new byte[1024 * 128];
|
|
var lastUpdate = DateTime.MinValue;
|
|
|
|
using (var input = response.GetResponseStream())
|
|
using (var output = File.Create(partialPath))
|
|
{
|
|
if (input == null)
|
|
{
|
|
throw new InvalidOperationException("Could not download OpenCode Desktop. The server returned no file content.");
|
|
}
|
|
|
|
while (true)
|
|
{
|
|
var read = input.Read(buffer, 0, buffer.Length);
|
|
if (read <= 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
output.Write(buffer, 0, read);
|
|
downloadedBytes += read;
|
|
|
|
if ((DateTime.Now - lastUpdate).TotalMilliseconds >= 250)
|
|
{
|
|
lastUpdate = DateTime.Now;
|
|
UpdateDownloadStatus(label, downloadedBytes, totalBytes, startProgress, endProgress);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (expectedSize > 0 && downloadedBytes != expectedSize)
|
|
{
|
|
throw new InvalidOperationException("The OpenCode Desktop download was incomplete. Please check your internet connection and try again.");
|
|
}
|
|
|
|
if (File.Exists(destinationPath))
|
|
{
|
|
File.Delete(destinationPath);
|
|
}
|
|
|
|
File.Move(partialPath, destinationPath);
|
|
UpdateDownloadStatus(label, downloadedBytes, totalBytes, startProgress, endProgress);
|
|
}
|
|
}
|
|
|
|
private void UpdateDownloadStatus(string label, long downloadedBytes, long totalBytes, int startProgress, int endProgress)
|
|
{
|
|
var downloadedMb = downloadedBytes / 1024.0 / 1024.0;
|
|
|
|
if (totalBytes > 0)
|
|
{
|
|
var totalMb = totalBytes / 1024.0 / 1024.0;
|
|
var ratio = Math.Max(0, Math.Min(1, downloadedBytes / (double)totalBytes));
|
|
var progress = startProgress + (int)((endProgress - startProgress) * ratio);
|
|
SetStatus("Downloading " + label + "... " + downloadedMb.ToString("0.0") + " MB of " + totalMb.ToString("0.0") + " MB", progress);
|
|
return;
|
|
}
|
|
|
|
SetStatus("Downloading " + label + "... " + downloadedMb.ToString("0.0") + " MB", startProgress);
|
|
}
|
|
|
|
private void WriteOpenCodeConfig(string apiKey, List<ModelOption> selectedModels)
|
|
{
|
|
SetStatus("Writing OpenCode configuration...", 85);
|
|
|
|
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
var configDirectory = Path.Combine(userProfile, ".config", "opencode");
|
|
var configPath = Path.Combine(configDirectory, "opencode.json");
|
|
|
|
Directory.CreateDirectory(configDirectory);
|
|
|
|
if (File.Exists(configPath))
|
|
{
|
|
var backupPath = configPath + ".backup-" + DateTime.Now.ToString("yyyyMMdd-HHmmss");
|
|
File.Copy(configPath, backupPath, true);
|
|
}
|
|
|
|
File.WriteAllText(configPath, BuildConfigJson(apiKey, selectedModels), new UTF8Encoding(false));
|
|
}
|
|
|
|
private static string BuildConfigJson(string apiKey, List<ModelOption> selectedModels)
|
|
{
|
|
var modelsJson = new StringBuilder();
|
|
for (var i = 0; i < selectedModels.Count; i++)
|
|
{
|
|
var model = selectedModels[i];
|
|
modelsJson.Append(" \"");
|
|
modelsJson.Append(JsonEscape(model.Id));
|
|
modelsJson.Append("\": { \"name\": \"");
|
|
modelsJson.Append(JsonEscape(model.Name));
|
|
modelsJson.Append("\" }");
|
|
if (i < selectedModels.Count - 1)
|
|
{
|
|
modelsJson.Append(",");
|
|
}
|
|
|
|
modelsJson.Append("\n");
|
|
}
|
|
|
|
return "{\n" +
|
|
" \"$schema\": \"https://opencode.ai/config.json\",\n" +
|
|
" \"enabled_providers\": [\"gesis\"],\n" +
|
|
" \"disabled_providers\": [\"zen\", \"opencode\"],\n" +
|
|
" \"provider\": {\n" +
|
|
" \"gesis\": {\n" +
|
|
" \"npm\": \"@ai-sdk/openai-compatible\",\n" +
|
|
" \"name\": \"GESIS AI Server\",\n" +
|
|
" \"options\": {\n" +
|
|
" \"baseURL\": \"" + JsonEscape(BaseUrl) + "\",\n" +
|
|
" \"apiKey\": \"" + JsonEscape(apiKey) + "\"\n" +
|
|
" },\n" +
|
|
" \"models\": {\n" +
|
|
modelsJson +
|
|
" }\n" +
|
|
" }\n" +
|
|
" }\n" +
|
|
"}\n";
|
|
}
|
|
|
|
private List<ModelOption> ProbeAvailableModels(string apiKey)
|
|
{
|
|
var endpoints = new[]
|
|
{
|
|
BaseUrl.TrimEnd('/') + "/models",
|
|
BaseUrl.TrimEnd('/') + "/v1/models"
|
|
};
|
|
|
|
Exception lastError = null;
|
|
var sawEmptyModelList = false;
|
|
foreach (var endpoint in endpoints)
|
|
{
|
|
try
|
|
{
|
|
SetStatus("Checking available models...", 20);
|
|
var json = DownloadString(endpoint, apiKey);
|
|
var models = ParseModels(json);
|
|
if (models.Count == 0)
|
|
{
|
|
sawEmptyModelList = true;
|
|
throw new InvalidOperationException("The GESIS AI Server returned no models for this API key.");
|
|
}
|
|
|
|
models.Sort((left, right) => String.Compare(left.Name, right.Name, StringComparison.CurrentCultureIgnoreCase));
|
|
return models;
|
|
}
|
|
catch (WebException ex)
|
|
{
|
|
lastError = ex;
|
|
var response = ex.Response as HttpWebResponse;
|
|
if (response != null && (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden))
|
|
{
|
|
throw new InvalidOperationException("The API key was rejected by the GESIS AI Server.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
lastError = ex;
|
|
}
|
|
}
|
|
|
|
if (lastError is WebException)
|
|
{
|
|
throw new InvalidOperationException("The installer could not reach the GESIS AI Server or could not retrieve its model list.");
|
|
}
|
|
|
|
if (sawEmptyModelList)
|
|
{
|
|
throw new InvalidOperationException("No models are available for this API key.");
|
|
}
|
|
|
|
throw new InvalidOperationException("The GESIS AI Server returned a model list in an unexpected format.");
|
|
}
|
|
|
|
private static List<ModelOption> ParseModels(string json)
|
|
{
|
|
var serializer = new JavaScriptSerializer();
|
|
var parsed = serializer.DeserializeObject(json);
|
|
var modelItems = FindModelItems(parsed);
|
|
if (modelItems == null)
|
|
{
|
|
throw new InvalidOperationException("The server returned a model list in an unexpected format.");
|
|
}
|
|
|
|
var byId = new Dictionary<string, ModelOption>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var item in modelItems)
|
|
{
|
|
var model = ToModelOption(item);
|
|
if (model != null && !byId.ContainsKey(model.Id))
|
|
{
|
|
byId.Add(model.Id, model);
|
|
}
|
|
}
|
|
|
|
return new List<ModelOption>(byId.Values);
|
|
}
|
|
|
|
private static object[] FindModelItems(object parsed)
|
|
{
|
|
var array = parsed as object[];
|
|
if (array != null)
|
|
{
|
|
return array;
|
|
}
|
|
|
|
var dictionary = parsed as Dictionary<string, object>;
|
|
if (dictionary == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var keys = new[] { "data", "models" };
|
|
foreach (var key in keys)
|
|
{
|
|
if (dictionary.ContainsKey(key))
|
|
{
|
|
var nestedArray = dictionary[key] as object[];
|
|
if (nestedArray != null)
|
|
{
|
|
return nestedArray;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static ModelOption ToModelOption(object item)
|
|
{
|
|
var id = "";
|
|
var name = "";
|
|
|
|
var text = item as string;
|
|
if (text != null)
|
|
{
|
|
id = text.Trim();
|
|
name = PrettyName(id);
|
|
}
|
|
else
|
|
{
|
|
var dictionary = item as Dictionary<string, object>;
|
|
if (dictionary == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
id = GetStringValue(dictionary, "id");
|
|
if (id.Length == 0)
|
|
{
|
|
id = GetStringValue(dictionary, "model");
|
|
}
|
|
|
|
name = GetStringValue(dictionary, "name");
|
|
if (name.Length == 0)
|
|
{
|
|
name = GetStringValue(dictionary, "title");
|
|
}
|
|
|
|
if (name.Length == 0)
|
|
{
|
|
name = PrettyName(id);
|
|
}
|
|
}
|
|
|
|
if (id.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new ModelOption(id, name);
|
|
}
|
|
|
|
private static string GetStringValue(Dictionary<string, object> dictionary, string key)
|
|
{
|
|
if (!dictionary.ContainsKey(key) || dictionary[key] == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return Convert.ToString(dictionary[key]).Trim();
|
|
}
|
|
|
|
private static string PrettyName(string modelId)
|
|
{
|
|
if (String.IsNullOrWhiteSpace(modelId))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return modelId.Replace("-", " ").Replace("_", " ");
|
|
}
|
|
|
|
private List<ModelOption> GetSelectedModels()
|
|
{
|
|
SyncVisibleModelSelection();
|
|
var selected = new List<ModelOption>();
|
|
foreach (var model in availableModels)
|
|
{
|
|
if (model.IsSelected)
|
|
{
|
|
selected.Add(model);
|
|
}
|
|
}
|
|
|
|
return selected;
|
|
}
|
|
|
|
private void RefreshModelList()
|
|
{
|
|
var filter = modelFilterBox.Text.Trim();
|
|
modelGridPanel.SuspendLayout();
|
|
try
|
|
{
|
|
modelGridPanel.Controls.Clear();
|
|
modelGridPanel.ColumnStyles.Clear();
|
|
modelGridPanel.RowStyles.Clear();
|
|
|
|
var visibleModels = new List<ModelOption>();
|
|
foreach (var model in availableModels)
|
|
{
|
|
if (filter.Length > 0 &&
|
|
model.Id.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0 &&
|
|
model.Name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
visibleModels.Add(model);
|
|
}
|
|
|
|
var columns = GetModelGridColumnCount();
|
|
modelGridPanel.ColumnCount = columns;
|
|
modelGridPanel.RowCount = Math.Max(1, (int)Math.Ceiling(visibleModels.Count / (double)columns));
|
|
|
|
for (var column = 0; column < columns; column++)
|
|
{
|
|
modelGridPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F / columns));
|
|
}
|
|
|
|
for (var row = 0; row < modelGridPanel.RowCount; row++)
|
|
{
|
|
modelGridPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
|
}
|
|
|
|
for (var i = 0; i < visibleModels.Count; i++)
|
|
{
|
|
var model = visibleModels[i];
|
|
var checkBox = new CheckBox
|
|
{
|
|
Text = model.ToString(),
|
|
Checked = model.IsSelected,
|
|
AutoSize = true,
|
|
MaximumSize = new Size(GetModelColumnTextWidth(columns), 0),
|
|
Margin = new Padding(0, 4, 18, 4),
|
|
Tag = model
|
|
};
|
|
checkBox.CheckedChanged += ModelCheckBox_CheckedChanged;
|
|
|
|
var row = i / columns;
|
|
var column = i % columns;
|
|
modelGridPanel.Controls.Add(checkBox, column, row);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
modelGridPanel.ResumeLayout(true);
|
|
}
|
|
}
|
|
|
|
private void SyncVisibleModelSelection()
|
|
{
|
|
foreach (Control control in modelGridPanel.Controls)
|
|
{
|
|
var checkBox = control as CheckBox;
|
|
if (checkBox == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var model = checkBox.Tag as ModelOption;
|
|
if (model != null)
|
|
{
|
|
model.IsSelected = checkBox.Checked;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SetVisibleModelSelection(bool selected)
|
|
{
|
|
foreach (Control control in modelGridPanel.Controls)
|
|
{
|
|
var checkBox = control as CheckBox;
|
|
if (checkBox == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var model = checkBox.Tag as ModelOption;
|
|
if (model != null)
|
|
{
|
|
model.IsSelected = selected;
|
|
checkBox.Checked = selected;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ModelCheckBox_CheckedChanged(object sender, EventArgs e)
|
|
{
|
|
var checkBox = sender as CheckBox;
|
|
if (checkBox == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var model = checkBox.Tag as ModelOption;
|
|
if (model != null)
|
|
{
|
|
model.IsSelected = checkBox.Checked;
|
|
}
|
|
}
|
|
|
|
private int GetModelGridColumnCount()
|
|
{
|
|
var workingArea = Screen.FromControl(this).WorkingArea;
|
|
if (workingArea.Width >= 1200 && availableModels.Count >= 9)
|
|
{
|
|
return 3;
|
|
}
|
|
|
|
if (workingArea.Width >= 800 && availableModels.Count >= 4)
|
|
{
|
|
return 2;
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
private int GetModelColumnTextWidth(int columns)
|
|
{
|
|
var contentWidth = Math.Max(420, ClientSize.Width - 96);
|
|
return Math.Max(220, (contentWidth / Math.Max(1, columns)) - 28);
|
|
}
|
|
|
|
private void ResizeForModelGrid()
|
|
{
|
|
if (InvokeRequired)
|
|
{
|
|
BeginInvoke(new Action(ResizeForModelGrid));
|
|
return;
|
|
}
|
|
|
|
if (WindowState == FormWindowState.Maximized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var workingArea = Screen.FromControl(this).WorkingArea;
|
|
var maxWidth = (int)(workingArea.Width * 0.92);
|
|
var maxHeight = (int)(workingArea.Height * 0.92);
|
|
var columns = GetModelGridColumnCount();
|
|
var rows = Math.Max(1, (int)Math.Ceiling(availableModels.Count / (double)columns));
|
|
var desiredWidth = columns >= 3 ? 980 : columns == 2 ? 820 : 680;
|
|
var desiredHeight = 430 + (rows * Math.Max(34, Font.Height + 18));
|
|
|
|
Width = Math.Min(Math.Max(Width, desiredWidth), maxWidth);
|
|
Height = Math.Min(Math.Max(Height, desiredHeight), maxHeight);
|
|
CenterToScreen();
|
|
}
|
|
|
|
private static string JsonEscape(string value)
|
|
{
|
|
if (value == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return value
|
|
.Replace("\\", "\\\\")
|
|
.Replace("\"", "\\\"")
|
|
.Replace("\r", "\\r")
|
|
.Replace("\n", "\\n")
|
|
.Replace("\t", "\\t");
|
|
}
|
|
|
|
private static string DownloadString(string url)
|
|
{
|
|
using (var client = CreateWebClient())
|
|
{
|
|
return client.DownloadString(url);
|
|
}
|
|
}
|
|
|
|
private static string DownloadString(string url, string apiKey)
|
|
{
|
|
using (var client = CreateWebClient())
|
|
{
|
|
client.Headers.Add("Authorization", "Bearer " + apiKey);
|
|
return client.DownloadString(url);
|
|
}
|
|
}
|
|
|
|
private static WebClient CreateWebClient()
|
|
{
|
|
var client = new WebClient();
|
|
client.Headers.Add("Accept", "application/vnd.github+json");
|
|
client.Headers.Add("User-Agent", "GESIS-OpenCode-Installer");
|
|
return client;
|
|
}
|
|
|
|
private void SetBusy(bool busy)
|
|
{
|
|
installButton.Enabled = !busy;
|
|
checkApiKeyButton.Enabled = !busy;
|
|
backButton.Enabled = !busy;
|
|
selectAllButton.Enabled = !busy;
|
|
selectNoneButton.Enabled = !busy;
|
|
apiKeyBox.Enabled = !busy;
|
|
if (!busy && installButton.Visible)
|
|
{
|
|
apiKeyBox.Enabled = false;
|
|
}
|
|
modelFilterBox.Enabled = !busy;
|
|
modelGridPanel.Enabled = !busy;
|
|
skipDesktopCheckBox.Enabled = !busy;
|
|
progressBar.Style = busy ? ProgressBarStyle.Marquee : ProgressBarStyle.Continuous;
|
|
if (!busy)
|
|
{
|
|
progressBar.Style = ProgressBarStyle.Continuous;
|
|
}
|
|
}
|
|
|
|
private void SetStatus(string text, int progress)
|
|
{
|
|
if (InvokeRequired)
|
|
{
|
|
BeginInvoke(new Action<string, int>(SetStatus), text, progress);
|
|
return;
|
|
}
|
|
|
|
progressBar.Style = ProgressBarStyle.Continuous;
|
|
progressBar.Value = Math.Max(0, Math.Min(100, progress));
|
|
statusLabel.Text = text;
|
|
}
|
|
}
|
|
|
|
internal sealed class ModelOption
|
|
{
|
|
public ModelOption(string id, string name)
|
|
{
|
|
Id = id;
|
|
Name = String.IsNullOrWhiteSpace(name) ? id : name;
|
|
IsSelected = true;
|
|
}
|
|
|
|
public string Id { get; private set; }
|
|
public string Name { get; private set; }
|
|
public bool IsSelected { get; set; }
|
|
|
|
public override string ToString()
|
|
{
|
|
if (String.Equals(Id, Name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Id;
|
|
}
|
|
|
|
return Name + " (" + Id + ")";
|
|
}
|
|
}
|
|
}
|