基线:全量源码首提(D1 版本控制落地,含第一轮优化 B1-B4/A1/A4/缩放修复)

This commit is contained in:
dqb-dev
2026-10-01 22:15:22 +08:00
commit 1e65792b09
499 changed files with 204579 additions and 0 deletions
+726
View File
@@ -0,0 +1,726 @@
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);
}
}
}
}