Files
ctbjrj/成套报价软件/OpenClawHost.cs
T

727 lines
30 KiB
C#
Raw 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.
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text;
namespace 成套报价软件
{
/// <summary>
/// ZCode AI 引擎(zai-org/ZCode 开源版)连接管理(★2026-09-26 由 OpenClaw 换芯为 ZCode,
/// 类名保留 OpenClawHost 减少调用点改动)。网关由 ai助手\启动引擎.cmd 拉起(内部跑
/// zcode\bin\zcode.mjs --web --token …),本类只负责:预置补全、启动参数落盘、健康探测、
/// 令牌/端口管理、开机自启登记。
/// 运行时布局:引擎目录(\ai助手)\zcode\bin\zcode.mjs + zcode\node\node.exe(随包发行,Apache-2.0)
/// 数据目录:引擎目录\zcode数据(经环境变量 ZCODE_DATA_BASE_DIR 隔离——技能/配置/凭据全在里面,
/// 不碰用户自己的 ~/.zcode;删除该目录即完全重置 AI 助手)
/// 令牌目录:用户主目录\.zcode\chengtao\(token.txt+port.txt——技能脚本的固定读取路径)
/// 探测:GET http://127.0.0.1:端口/api/server-info?token=… 返回 200 即就绪。
/// ★模型选择完全用 ZCode 自带默认行为,本类不写任何模型选择/清单配置(用户 2026-09-26 要求)。
/// </summary>
internal static class OpenClawHost
{
private const int 默认端口 = 18789;
private static readonly object _锁 = new object();
private static readonly object _日志锁 = new object();
private static string _令牌;
private static int _端口 = 默认端口;
private static bool _已加载配置;
/// <summary>网关端口(引擎重启后生效)。</summary>
internal static int 端口 { get { return _端口; } }
/// <summary>连接令牌(首跑生成并持久化,Web 面板经 URL ?token= 认证,技能回调主程序共用)。</summary>
internal static string 令牌 { get { 加载配置(); return _令牌; } }
/// <summary>引擎目录(启动引擎.cmd 所在目录):优先 exe 旁(安装布局),
/// 开发机回退 项目根\ai助手(bin\Debug 起三层 ..)。</summary>
internal static string 引擎目录()
{
string exeDir = AppDomain.CurrentDomain.BaseDirectory;
string a = Path.Combine(exeDir, "ai助手");
if (File.Exists(Path.Combine(a, "启动引擎.cmd"))) return a;
try
{
// 开发机: ...\成套报价软件\成套报价软件\bin\Debug\ → 三层 .. 才是方案根
string[] devs = {
Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "ai助手")),
Path.GetFullPath(Path.Combine(exeDir, "..", "..", "ai助手"))
};
foreach (string dev in devs)
{
if (File.Exists(Path.Combine(dev, "启动引擎.cmd"))) return dev;
}
}
catch { }
return a;
}
/// <summary>ZCode 运行时(发行包入口 + 捆绑 node)是否齐全。</summary>
internal static bool 运行时已安装()
{
string 根 = 引擎目录();
return File.Exists(Path.Combine(根, "zcode", "bin", "zcode.mjs"))
&& File.Exists(Path.Combine(根, "zcode", "node", "node.exe"));
}
/// <summary>引擎数据目录(ZCODE_DATA_BASE_DIR 隔离;技能在 zcode数据\.zcode\skills、
/// provider/凭据在 zcode数据\.zcode\v2;与用户自己的 ~/.zcode 完全互不影响)。</summary>
internal static string 数据目录()
{
return Path.Combine(引擎目录(), "zcode数据");
}
/// <summary>引擎状态目录(设置对话框"复制状态目录"用;= 数据目录)。</summary>
internal static string 状态目录()
{
return 数据目录();
}
/// <summary>令牌/端口文件目录(用户主目录\.zcode\chengtao\,纯 ASCII 路径防 cmd 编码坑):
/// 技能脚本(xuanxing.js/SKILL.md)与 启动引擎.cmd 都从这里读固定路径,与安装位置无关。</summary>
private static string 令牌目录()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".zcode", "chengtao");
}
/// <summary>网关诊断日志(本类写连接/配置事件,引擎自身日志在 ai助手\引擎控制台.log)。</summary>
internal static string 网关日志路径()
{
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "ai助手_gateway.log");
}
/// <summary>写一行诊断日志(DockWindow10 也复用)。</summary>
internal static void 写日志(string 文本)
{
try
{
lock (_日志锁)
{
string f = 网关日志路径();
string dir = Path.GetDirectoryName(f);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
if (File.Exists(f) && new FileInfo(f).Length > 2 * 1024 * 1024)
File.WriteAllText(f, "", Encoding.UTF8); // 超过 2MB 清零重写,防无限膨胀
File.AppendAllText(f, DateTime.Now.ToString("HH:mm:ss.fff ") + 文本 + Environment.NewLine, Encoding.UTF8);
}
}
catch { }
}
// ---------- 配置(shezhi\AI助手.config.xml:软件侧端口/令牌登记) ----------
private static string 配置路径()
{
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "shezhi", "AI助手.config.xml");
}
private static void 加载配置()
{
lock (_锁)
{
if (_已加载配置) return;
_已加载配置 = true;
try
{
string f = 配置路径();
if (File.Exists(f))
{
string xml = File.ReadAllText(f, Encoding.UTF8);
_端口 = 提取XML值(xml, "端口", 默认端口);
_令牌 = 提取XML值(xml, "令牌", "");
}
}
catch { }
if (_端口 < 1024 || _端口 > 65535) _端口 = 默认端口;
if (string.IsNullOrEmpty(_令牌))
_令牌 = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N").Substring(0, 16);
保存配置();
}
}
private static void 保存配置()
{
try
{
string f = 配置路径();
string dir = Path.GetDirectoryName(f);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
StringBuilder sb = new StringBuilder();
sb.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
sb.AppendLine("<!-- AI助手(ZCode 开源版)配置:端口=网页服务端口;令牌=面板/技能连接令牌,删除文件可重置 -->");
sb.AppendLine("<AI助手>");
sb.AppendLine(" <端口>" + _端口 + "</端口>");
sb.AppendLine(" <令牌>" + _令牌 + "</令牌>");
sb.AppendLine("</AI助手>");
File.WriteAllText(f, sb.ToString(), Encoding.UTF8);
}
catch { }
}
/// <summary>设置对话框改端口用(保存到配置并刷新启动参数文件,重启引擎后生效)。</summary>
internal static void 保存端口(int 新端口)
{
加载配置();
lock (_锁)
{
if (新端口 >= 1024 && 新端口 <= 65535 && 新端口 != _端口)
{
_端口 = 新端口;
保存配置();
写启动参数();
}
}
}
private static int 提取XML值(string xml, string 标签, int 缺省)
{
string v = 提取XML值(xml, 标签, "");
int n;
if (int.TryParse(v, out n)) return n;
return 缺省;
}
private static string 提取XML值(string xml, string 标签, string 缺省)
{
if (string.IsNullOrEmpty(xml)) return 缺省;
string 开 = "<" + 标签 + ">", 关 = "</" + 标签 + ">";
int i = xml.IndexOf(开, StringComparison.Ordinal);
if (i < 0) return 缺省;
int j = xml.IndexOf(关, i + 开.Length, StringComparison.Ordinal);
if (j < 0) return 缺省;
return xml.Substring(i + 开.Length, j - i - 开.Length).Trim();
}
// ---------- 启动参数落盘(令牌目录:令牌.txt + 端口.txt) ----------
/// <summary>确保配置已就绪:预置补全 → 加载软件侧配置 → 把端口/令牌写进令牌目录
/// (技能与启动脚本读这里)。纯文件操作。</summary>
internal static void EnsureConfigured()
{
预置补全(); // ★ 新装机器:先补技能/provider配置/密钥(缺什么补什么,已有的绝不动)
加载配置();
写启动参数();
}
private static void 写启动参数()
{
try
{
// ★UTF8Encoding(false)=无 BOM:cmd 的 set /p 与 JSON.parse 都不接受 BOM 前缀
var utf8无BOM = new UTF8Encoding(false);
string tk = _令牌 ?? "";
string pt = (_端口 > 0 ? _端口 : 默认端口).ToString();
// 双写两处:真实用户目录(停止脚本/手动工具读) + 引擎数据目录(引擎进程 HOME/USERPROFILE
// 已被 启动引擎.cmd 指到数据目录——会话隔离后引擎内的 ~/.zcode/chengtao 即此路径)
string d1 = 令牌目录();
Directory.CreateDirectory(d1);
File.WriteAllText(Path.Combine(d1, "token.txt"), tk, utf8无BOM);
File.WriteAllText(Path.Combine(d1, "port.txt"), pt, utf8无BOM);
string d2 = Path.Combine(数据目录(), ".zcode", "chengtao");
try
{
Directory.CreateDirectory(d2);
File.WriteAllText(Path.Combine(d2, "token.txt"), tk, utf8无BOM);
File.WriteAllText(Path.Combine(d2, "port.txt"), pt, utf8无BOM);
}
catch { } // 数据目录写不进不阻塞(引擎侧还有真实目录兜底)
}
catch (Exception ex) { 写日志("[AI助手] 写启动参数失败: " + ex.Message); }
}
// ---------- 新装机器预置补全(ai助手\zcode预置 → 引擎数据目录) ----------
/// <summary>把安装包自带的 AI 预置补装到引擎数据目录,缺什么补什么:
/// ① 技能(预置 skills\* 与 workshop-skills\* 全部 → zcode数据\workspace\.zcode\skills\)——
/// ★ZCode 技能根规则(roots.ts):用户级=~/.zcode(不吃数据目录隔离,装那会污染用户自己的
/// ZCode);项目级=工作区 cwd 的 .zcode\skills——引擎工作区固定为 zcode数据\workspace,
/// 技能装这里=随数据目录隔离+引擎可见。★技能带版本戳(zcode预置\技能版本.txt):
/// 版本变化时整份覆盖升级,否则只补缺目录;
/// ② 个人 provider 配置(zcode数据\.zcode\v2\provider_config.json):缺我们的 GLM 接入项时
/// 从 provider-模板.json 合并(anthropic-messages @ open.bigmodel.cn,GLM-5.3/GLM-5.3-Flash);
/// ③ zai密钥.txt 已填有效密钥且我们的接入项还没有 apiKey → 直接写入配置文件
/// (纯 JSON 文件操作,注册密钥.cmd 退役)。
/// ★模型选择完全不碰——用 ZCode 自带默认行为(用户 2026-09-26 要求)。
/// 用户自己的配置/密钥绝不动;技能为软件托管内容,随版本升级覆盖。</summary>
private static void 预置补全()
{
try
{
string 预置 = Path.Combine(引擎目录(), "zcode预置");
if (!Directory.Exists(预置)) return;
string 数据 = 数据目录();
string 技能根 = Path.Combine(数据, "workspace", ".zcode", "skills");
// ① 技能——带版本戳:缺目录补装,版本变化整份覆盖升级
// ★ 遍历预置 skills\* 与 workshop-skills\* 全部技能目录(ZCode 单一 skills 根,
// 两处预置都铺到同一目标):以后预置里加新技能自动装/升,不用改代码
string 预置版本文件 = Path.Combine(预置, "技能版本.txt");
string 已装版本文件 = Path.Combine(技能根, "dqb预置版本.txt");
string 预置版本 = File.Exists(预置版本文件)
? File.ReadAllText(预置版本文件, Encoding.UTF8).Trim() : "";
string 已装版本 = File.Exists(已装版本文件)
? File.ReadAllText(已装版本文件, Encoding.UTF8).Trim() : "";
bool 版本变了 = 预置版本.Length > 0 && 预置版本 != 已装版本;
var 技能对 = new List<KeyValuePair<string, string>>();
string 预置技能根 = Path.Combine(预置, "skills");
if (Directory.Exists(预置技能根))
{
foreach (string d in Directory.GetDirectories(预置技能根))
技能对.Add(new KeyValuePair<string, string>(
d, Path.Combine(技能根, Path.GetFileName(d))));
}
string 预置工作坊根 = Path.Combine(预置, "workshop-skills");
if (Directory.Exists(预置工作坊根))
{
foreach (string d in Directory.GetDirectories(预置工作坊根))
技能对.Add(new KeyValuePair<string, string>(
d, Path.Combine(技能根, Path.GetFileName(d))));
}
if (版本变了)
{
// 覆盖升级:先删旧目录再整份拷(技能是软件托管内容,随包升级)
int 升级数 = 0;
foreach (var kv in 技能对)
{
覆盖技能目录(kv.Key, kv.Value);
升级数++;
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(已装版本文件));
File.WriteAllText(已装版本文件, 预置版本, Encoding.UTF8);
}
catch { }
写日志("[AI助手] 预置补全:已升级 " + 升级数 + " 个技能到版本 " + 预置版本);
}
else
{
int 补技能数 = 0;
foreach (var kv in 技能对)
{
if (补目录(kv.Key, kv.Value)) 补技能数++;
}
if (补技能数 > 0) 写日志("[AI助手] 预置补全:已安装 " + 补技能数 + " 个技能");
}
// ② 个人 provider 配置(缺项合并)+ ③ 密钥直写
合并个人Provider配置(预置, 数据);
// ④ 内置插件 seed(2026-09-26 用户报"旧版技能好多没生效"):会话隔离(HOME=数据目录)
// 后引擎看不到用户桌面版的插件缓存——开源仓库自带的两个官方内置插件
// (browser-use 浏览器自动化/node-repl-host)从预置种进隔离目录,离线可用;
// documents/pdf 等其余插件为桌面版专有(开源仓库无源码),CDN 社区市场另有 26 个可联网装。
Seed内置插件(预置, 数据);
}
catch (Exception ex) { 写日志("[AI助手] 预置补全异常: " + ex.Message); }
}
/// <summary>内置插件 seed:预置\内置插件\&lt;插件名&gt;\(目录名=插件名,内含 .zcode-plugin\plugin.json)
/// → 数据目录\.zcode\cli\plugins\cache\zcode-plugins-official\&lt;名&gt;\&lt;版本&gt;\,
/// 并写 bundled-marketplace.json 分片与合并 marketplace.json(ZCode 原生 seed 格式)。
/// 幂等:cache 里已有该插件则跳过;纯文件操作,无需网络。</summary>
private static void Seed内置插件(string 预置, string 数据)
{
try
{
string 预置插件根 = Path.Combine(预置, "内置插件");
if (!Directory.Exists(预置插件根)) return;
string plugins根 = Path.Combine(数据, ".zcode", "cli", "plugins");
string cache根 = Path.Combine(plugins根, "cache", "zcode-plugins-official");
var 已种 = new List<Dictionary<string, object>>();
foreach (string 源 in Directory.GetDirectories(预置插件根))
{
string 名 = Path.GetFileName(源);
string 清单 = Path.Combine(源, ".zcode-plugin", "plugin.json");
if (!File.Exists(清单)) continue;
// 版本从 plugin.json 读(读失败跳过该插件)
string 版本 = "";
try
{
var jp = new System.Web.Script.Serialization.JavaScriptSerializer();
object oP = jp.Deserialize<object>(File.ReadAllText(清单, Encoding.UTF8));
var pd = oP as Dictionary<string, object>;
object oV;
if (pd != null && pd.TryGetValue("version", out oV)) 版本 = Convert.ToString(oV);
}
catch { }
if (版本.Length == 0) continue;
string 目标 = Path.Combine(cache根, 名, 版本);
if (Directory.Exists(目标)) { 已种.Add(种清单项(名, 版本, 目标)); continue; } // 已种过,只登记
Directory.CreateDirectory(Path.GetDirectoryName(目标));
递归复制目录(源, 目标);
已种.Add(种清单项(名, 版本, 目标));
写日志("[AI助手] 预置补全:已种内置插件 " + 名 + "@" + 版本);
}
if (已种.Count == 0) return;
// 写 bundled 分片 + 合并 marketplace.json(与官方 seed 同构;CDN 刷新会保留 bundled 分片)
var manifest = new Dictionary<string, object>();
manifest["name"] = "zcode-plugins-official";
manifest["description"] = "Official ZCode plugins marketplace: built-in and community plugins for ZCode.";
manifest["plugins"] = 已种.ToArray();
var 分片 = new Dictionary<string, object>();
分片["manifest"] = manifest;
分片["version"] = 1;
Directory.CreateDirectory(plugins根);
string json = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(分片);
File.WriteAllText(Path.Combine(plugins根, "bundled-marketplace.json"), json, new UTF8Encoding(false));
File.WriteAllText(Path.Combine(plugins根, "marketplace.json"), json, new UTF8Encoding(false));
}
catch (Exception ex) { 写日志("[AI助手] 内置插件 seed 异常: " + ex.Message); }
}
private static Dictionary<string, object> 种清单项(string 名, string 版本, string cache路径)
{
var d = new Dictionary<string, object>();
d["name"] = 名;
d["version"] = 版本;
d["cachePath"] = cache路径;
return d;
}
/// <summary>目标目录不存在时把源目录整份(含子目录)复制过去。
/// 已存在=用户装过/改过,一律不动。返回 true=本次复制了。</summary>
private static bool 补目录(string 源, string 目标)
{
try
{
if (!Directory.Exists(源) || Directory.Exists(目标)) return false;
递归复制目录(源, 目标);
return true;
}
catch (Exception ex) { 写日志("[AI助手] 补技能失败 " + 目标 + ": " + ex.Message); return false; }
}
/// <summary>技能版本升级:删旧目录整份重拷(技能是软件托管内容,随包覆盖升级;
/// 与 补目录 的"已存在不动"互补——那个管首装,这个管升级)。</summary>
private static void 覆盖技能目录(string 源, string 目标)
{
try
{
if (!Directory.Exists(源)) return;
if (Directory.Exists(目标))
{
try { Directory.Delete(目标, true); }
catch (Exception exD) { 写日志("[AI助手] 技能升级:旧目录删除失败 " + 目标 + ": " + exD.Message); }
}
递归复制目录(源, 目标);
}
catch (Exception ex) { 写日志("[AI助手] 技能升级失败 " + 目标 + ": " + ex.Message); }
}
/// <summary>递归复制目录(含子目录与文件,全部覆盖)。</summary>
private static void 递归复制目录(string 源, string 目标)
{
Directory.CreateDirectory(目标);
foreach (string f in Directory.GetFiles(源))
File.Copy(f, Path.Combine(目标, Path.GetFileName(f)), true);
foreach (string d in Directory.GetDirectories(源))
递归复制目录(d, Path.Combine(目标, Path.GetFileName(d)));
}
/// <summary>个人 provider 配置(zcode数据\.zcode\v2\provider_config.json,ZCode 原生格式
/// {schemaVersion:1, config:{providerConfigRules:{providerRules:[{providerId,config:{…}}]}}}):
/// ① 没有我们的接入项(providerId=dqb-glm)时从 provider-模板.json 合并该规则(用户已配的绝不动);
/// ② 接入项 apiKey 为空 且 zai密钥.txt 已填有效密钥 → 直接写进 apiKey 字段。
/// ★只定义"接入点+模型清单",不写 defaultModelSelection——默认模型用 ZCode 自带行为。</summary>
private static void 合并个人Provider配置(string 预置, string 数据)
{
try
{
string 模板文件 = Path.Combine(预置, "provider-模板.json");
if (!File.Exists(模板文件)) return;
var js = new System.Web.Script.Serialization.JavaScriptSerializer();
// 读密钥(首行,>20 字符且非注释)
string 密钥 = "";
try
{
string 密钥文件 = Path.Combine(预置, "zai密钥.txt");
if (File.Exists(密钥文件))
{
string[] 行 = File.ReadAllText(密钥文件, Encoding.UTF8)
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
string 首行 = 行.Length > 0 ? 行[0].Trim() : "";
if (首行.Length > 20 && !首行.StartsWith("#")) 密钥 = 首行;
}
}
catch { }
// 模板 → 我们的接入规则(config.providerConfigRules.providerRules 第一项)
object oT;
var 模板根 = js.Deserialize<object>(File.ReadAllText(模板文件, Encoding.UTF8)) as Dictionary<string, object>;
var 模板config = (模板根 != null && 模板根.TryGetValue("config", out oT)) ? oT as Dictionary<string, object> : null;
var 模板规则容器 = (模板config != null && 模板config.TryGetValue("providerConfigRules", out oT)) ? oT as Dictionary<string, object> : null;
object[] 模板规则 = (模板规则容器 != null && 模板规则容器.TryGetValue("providerRules", out oT)) ? oT as object[] : null;
Dictionary<string, object> 模板项 = (模板规则 != null && 模板规则.Length > 0)
? 模板规则[0] as Dictionary<string, object> : null;
if (模板项 == null) { 写日志("[AI助手] provider-模板.json 格式不合(config.providerConfigRules.providerRules 缺失)"); return; }
string 接入键 = 模板项.TryGetValue("providerId", out oT) ? Convert.ToString(oT) : "";
// 现有配置(没有则按模板根新建——★schemaVersion=1 缺失引擎会拒绝整个文件)
string v2目录 = Path.Combine(数据, ".zcode", "v2");
Directory.CreateDirectory(v2目录);
string f = Path.Combine(v2目录, "provider_config.json");
var 根 = new Dictionary<string, object>();
根["schemaVersion"] = 1;
Dictionary<string, object> config = null;
if (File.Exists(f))
{
try
{
object o = js.Deserialize<object>(File.ReadAllText(f, Encoding.UTF8));
var 旧根 = o as Dictionary<string, object>;
if (旧根 != null)
{
根 = 旧根;
config = (旧根.TryGetValue("config", out oT)) ? oT as Dictionary<string, object> : null;
}
}
catch { 写日志("[AI助手] provider_config.json 解析失败(保留原文件,本次不合并)"); return; }
}
if (config == null) { config = new Dictionary<string, object>(); 根["config"] = config; }
var 规则容器 = (config.TryGetValue("providerConfigRules", out oT)) ? oT as Dictionary<string, object> : null;
if (规则容器 == null) { 规则容器 = new Dictionary<string, object>(); config["providerConfigRules"] = 规则容器; }
var 规则列表 = 规则容器.ContainsKey("providerRules") && 规则容器["providerRules"] is object[]
? new List<object>((object[])规则容器["providerRules"]) : new List<object>();
// 找我们的接入项;没有→追加模板项
Dictionary<string, object> 我们项 = null;
foreach (object oR in 规则列表)
{
var r = oR as Dictionary<string, object>;
if (r == null) continue;
object oId;
if (r.TryGetValue("providerId", out oId) && string.Equals(Convert.ToString(oId), 接入键, StringComparison.Ordinal))
{ 我们项 = r; break; }
}
bool 改 = false;
if (我们项 == null)
{
我们项 = 模板项;
规则列表.Add(我们项);
规则容器["providerRules"] = 规则列表.ToArray();
改 = true;
写日志("[AI助手] 预置补全:已添加 GLM 接入项 " + 接入键 + "(anthropic-messages @ open.bigmodel.cn)");
}
// 密钥直写(接入项 apiKey 空且密钥有效;注册密钥.cmd 退役)
if (密钥.Length > 0)
{
object oCfg;
var cfg = 我们项.TryGetValue("config", out oCfg) ? oCfg as Dictionary<string, object> : null;
object oAcc;
var acc = (cfg != null && cfg.TryGetValue("access", out oAcc)) ? oAcc as Dictionary<string, object> : null;
string 现钥 = acc != null && acc.TryGetValue("apiKey", out oAcc) ? Convert.ToString(oAcc) : "";
if (acc != null && string.IsNullOrEmpty(现钥))
{
acc["apiKey"] = 密钥;
改 = true;
写日志("[AI助手] 预置补全:已按 zai密钥.txt 写入 GLM 密钥");
}
}
else if (我们项 == 模板项)
{
写日志("[AI助手] 预置密钥文件未填有效密钥,跳过写入(填好后下次启动自动写入)");
}
if (改)
File.WriteAllText(f, js.Serialize(根), new UTF8Encoding(false)); // 无 BOM:JSON.parse 不接受 BOM
}
catch (Exception ex) { 写日志("[AI助手] 合并 provider 配置异常: " + ex.Message); }
}
// ---------- 引擎启停(ShellExecuteW 打开 ai助手 目录的静态脚本;不经 Process.Start) ----------
/// <summary>用系统 Shell 打开文件/程序(打开 .cmd 即在新窗口运行该脚本)。</summary>
[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern int ShellExecuteW(System.IntPtr hWnd, string verb, string file, string parameters, string directory, int showCmd);
/// <summary>启动 AI 引擎(主程序 Load 时调用):配置就绪+未运行才拉起 启动引擎.cmd(完全后台)。
/// 脚本内部:zcode\node\node.exe zcode\bin\zcode.mjs --web --port … --token …(参数读令牌目录)。
/// 返回 true=已运行或已发起启动。</summary>
internal static bool 启动引擎()
{
try
{
EnsureConfigured();
if (健康检查(_端口)) return true; // 已在运行(启动器/手动已拉起)
// ★ A4(2026-09-30):端口被占但不健康 = 僵尸进程(上次 AI 助手启动失败的根因:
// 残留 node 占着端口,健康检查失败→拉起新引擎→EADDRINUSE 崩溃)。
// 拉起前先杀掉占用者,等端口释放。
清理僵尸端口占用(_端口);
string 根 = 引擎目录();
if (!运行时已安装())
{
写日志("[AI助手] ZCode 运行时不完整(缺 zcode\\bin\\zcode.mjs 或 zcode\\node\\node.exe): " + 根);
return false;
}
string cmd = System.IO.Path.Combine(根, "启动引擎.cmd");
if (!System.IO.File.Exists(cmd))
{
写日志("[AI助手] 找不到 启动引擎.cmd: " + cmd);
return false;
}
ShellExecuteW(System.IntPtr.Zero, "open", cmd, null, 根, 0); // 0=SW_HIDE 完全后台(无窗口无任务栏项,用户要求)
写日志("[AI助手] 已发起引擎启动(软件自动拉起)");
return true;
}
catch (Exception ex)
{
写日志("[AI助手] 启动引擎失败: " + ex.Message);
return false;
}
}
/// <summary>
/// ★ A4(2026-09-30):端口被监听但健康检查失败 = 僵尸进程(异常退出残留的 node 等)。
/// 杀掉占用端口的进程并等待端口释放,再拉起新引擎(否则新引擎 EADDRINUSE 崩溃)。
/// 只杀 LISTENING 该端口的进程;PID 4(系统)不杀。
/// </summary>
private static void 清理僵尸端口占用(int p)
{
try
{
// 先无损探测:没有监听者就直接返回
bool occupied = false;
try
{
foreach (var ep in System.Net.NetworkInformation.IPGlobalProperties
.GetIPGlobalProperties().GetActiveTcpListeners())
{
if (ep.Port == p) { occupied = true; break; }
}
}
catch { }
if (!occupied) return;
// 有占用者且不健康 → netstat 拿 PID 杀掉
var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe",
"/c netstat -ano | findstr LISTENING | findstr :" + p.ToString() + " ")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using (var pr = System.Diagnostics.Process.Start(psi))
{
string outp = pr.StandardOutput.ReadToEnd();
pr.WaitForExit(3000);
foreach (string line in outp.Split('\n'))
{
var seg = line.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (seg.Length >= 5 && seg[2].EndsWith(":" + p.ToString()) && seg[3] == "LISTENING")
{
int pid;
if (int.TryParse(seg[4], out pid) && pid > 4) // PID 4=系统端口,不杀
{
try
{
var victim = System.Diagnostics.Process.GetProcessById(pid);
string pname = "";
try { pname = victim.ProcessName; } catch { }
victim.Kill();
写日志("[AI助手] 已清理占用端口 " + p + " 的僵尸进程 PID=" + pid + " (" + pname + ")");
}
catch (Exception exK) { 写日志("[AI助手] 清理 PID=" + pid + " 失败: " + exK.Message); }
}
}
}
}
System.Threading.Thread.Sleep(800); // 等 TIME_WAIT/端口释放
}
catch (Exception ex)
{
写日志("[AI助手] 清理端口占用异常: " + ex.Message);
}
}
/// <summary>停止 AI 引擎(主程序 FormClosing 时调用):跑 停止引擎静默.cmd(按端口杀监听进程)。
/// 未运行时静默跳过。</summary>
internal static void 停止引擎() {
try
{
if (!健康检查(_端口)) return;
string 根 = 引擎目录();
string cmd = System.IO.Path.Combine(根, "停止引擎静默.cmd");
if (!System.IO.File.Exists(cmd)) return;
ShellExecuteW(System.IntPtr.Zero, "open", cmd, null, 根, 0); // 0=SW_HIDE
写日志("[AI助手] 已随软件退出停止引擎");
}
catch (Exception ex)
{
写日志("[AI助手] 停止引擎失败: " + ex.Message);
}
}
// ---------- 健康探测 ----------
/// <summary>确保配置就绪并探测引擎是否在运行(供窗口/设置对话框轮询)。</summary>
internal static bool 网关就绪()
{
EnsureConfigured();
return 健康检查(_端口);
}
/// <summary>GET http://127.0.0.1:端口/api/server-info?token=令牌 是否 200。
/// /api/* 带鉴权——既探测服务在跑,又验证令牌匹配。
/// Proxy=null 防系统代理劫持 127.0.0.1(UmiOcrClient 同款教训)。</summary>
internal static bool 健康检查(int p)
{
try
{
var req = (HttpWebRequest)WebRequest.Create("http://127.0.0.1:" + p
+ "/api/server-info?token=" + Uri.EscapeDataString(令牌));
req.Method = "GET";
req.Timeout = 800;
req.ReadWriteTimeout = 800;
req.Proxy = null;
using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse())
{
return resp.StatusCode == HttpStatusCode.OK;
}
}
catch { return false; }
}
// ---------- 开机自启(往用户启动文件夹写一个包装 cmd,纯文件操作) ----------
private static string 自启包装路径()
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),
"成套报价AI引擎.cmd");
}
/// <summary>开机自启是否已登记。</summary>
internal static bool 自启已登记()
{
return File.Exists(自启包装路径());
}
/// <summary>登记/取消开机自启(包装 cmd 用绝对路径 call 引擎目录里的 启动引擎.cmd)。</summary>
internal static void 设置自启(bool 开)
{
try
{
string f = 自启包装路径();
if (!开)
{
if (File.Exists(f)) File.Delete(f);
return;
}
string 引擎 = 引擎目录();
StringBuilder sb = new StringBuilder();
sb.AppendLine("@echo off");
sb.AppendLine("call \"" + Path.Combine(引擎, "启动引擎.cmd") + "\"");
File.WriteAllText(f, sb.ToString(), Encoding.Default);
}
catch (Exception ex)
{
写日志("[AI助手] 设置开机自启失败: " + ex.Message);
}
}
}
}