727 lines
30 KiB
C#
727 lines
30 KiB
C#
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:预置\内置插件\<插件名>\(目录名=插件名,内含 .zcode-plugin\plugin.json)
|
||
/// → 数据目录\.zcode\cli\plugins\cache\zcode-plugins-official\<名>\<版本>\,
|
||
/// 并写 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);
|
||
}
|
||
}
|
||
}
|
||
}
|