news 2026/4/18 15:59:31

cfapi 入门实战(三):为什么需要占位符文件(Placeholder)?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
cfapi 入门实战(三):为什么需要占位符文件(Placeholder)?

云同步程序开发围绕Placeholder进行的!

这个微软官方定义占位符文件

生成支持占位符文件的云同步引擎 - Win32 apps | Microsoft Learn

  • 同步引擎可以创建只占用 1 KB 存储空间用于文件系统标头的占位符文件,并在正常使用条件下自动转变为完整文件。 占位符文件在 Windows Shell 中以典型文件的形式呈现给应用程序和最终用户。
  • 占位符文件从 Windows 内核垂直集成到 Windows Shell,并且与占位符文件的应用兼容性通常不是问题。 无论你是使用文件系统 API、命令提示符还是桌面或 UWP 应用来访问占位符文件,文件都会解除冻结,而无需进行其他代码更改,并且该应用可以正常使用该文件。
  • 文件可以存在于三种状态:
    • 占位符文件(Placeholder):文件的空表示形式,仅当同步服务可用时才可用。
    • 完整文件(Full):文件已隐式冻结,如果需要空间,系统可能会解除冻结。
    • 固定的完整文件(Full Pinned):该文件已由用户通过文件资源管理器下载,并保证可脱机使用。

下图演示了文件资源管理器中如何显示占位符、完整和固定的完整文件状态。

在真正开始写FetchData 回调、流式下载、同步状态更新之前,必须先理解一个核心问题:

CFAPI 为什么一定要用“占位符(Placeholder)”?

如果不理解这一点,后面几乎所有行为——
Explorer 显示、CreateFile 行为、OnFetchData 触发时机、同步状态图标——都会让人困惑。


一、CFAPI 的本质目标

CFAPI(Cloud Files API)的目标不是“做一个网盘下载器”,而是:

让远端数据,在本地看起来就像真实存在的文件系统对象

也就是说:

用户视角系统视角
文件已经在磁盘上实际数据可能完全不在本地
能看到文件名、大小、时间仅存在元数据
双击才下载按需拉取数据

占位符(Placeholder)正是实现这一点的基础设施。


二、什么是占位符(Placeholder)

一句话定义:

占位符是一个“没有真实文件数据,但具备完整文件系统元信息的文件 / 目录对象”

它存在于 NTFS 中,但内容是“空的”或“未实体化的”。

占位符具备什么?

✔ 文件名
✔ 路径
✔ 文件大小
✔ 创建 / 修改时间
✔ 文件属性
✔ 同步状态(云、已下载、部分下载)

占位符缺少什么?

✘ 实际文件数据(Data Stream)


三、没有占位符会发生什么?

假设你不用占位符,而是“真正下载后才创建文件”。

1️⃣ Explorer 无法显示远端文件

  • 文件不在磁盘上

  • Explorer 没有任何对象可枚举

  • 用户看到的是一个“空目录”

👉用户体验直接失败


2️⃣ CreateFile 无法被拦截

CFAPI 的核心是:

当用户或程序访问文件时,系统回调给 Provider

但前提是:

这个文件对象已经存在于 NTFS

没有占位符:

CreateFile("cloud.txt") → ERROR_FILE_NOT_FOUND

FetchData 根本不会触发


3️⃣ Windows 无法管理同步状态

这些状态图标:

  • ☁ 云端

  • ✔ 本地

  • ⬇ 正在下载

全部依赖于:

占位符 + CFAPI 状态机

没有占位符,Windows 不知道:

  • 这个文件是不是云文件

  • 是否允许按需下载

  • 是否可以释放本地数据


四、占位符在 CFAPI 中的地位

你可以把占位符理解为:

Cloud Files 的“合同文件”

CFAPI 的工作流程(简化)

远端元数据 ↓ CfCreatePlaceholders ↓ 占位符(NTFS 对象) ↓ 用户访问(CreateFile / Read) ↓ OnFetchData 回调 ↓ 下载真实数据 ↓ CfWriteFile / CfCompleteFetchData

👉没有占位符,就没有后续所有步骤

五、占位符 ≠ 空文件(非常关键)

这是新手最容易犯的错误。

项目占位符空文件
NTFS 对象
文件大小可为真实大小通常为 0
数据是否存在
触发 FetchData
支持按需下载

空文件一旦存在真实数据流,CFAPI 就认为“你已经有数据了”

这也是你之前遇到的:

  • CreateFile 直接触发下载

  • 或不触发 FetchData

  • 或 ERROR_CLOUD_FILE_INVALID_REQUEST

的根本原因之一。


六、为什么 CFAPI 要“先占位、后取数”

1️⃣ 性能

  • 百万级文件列表

  • 秒级展示目录结构

  • 不下载任何内容

2️⃣ 资源管理

  • 磁盘空间可控

  • CfDehydratePlaceholder 可释放本地数据

  • 系统自动回收

3️⃣ 系统一致性

  • Explorer

  • CMD / PowerShell

  • 第三方程序(CreateFile)

全部通过 NTFS 统一入口


七、结合你当前开发中的几个关键点

✔ 为什么只是CreateFile就触发OnFetchData

因为:

  • 打开的是占位符

  • 访问方式涉及数据访问

  • 系统认为需要实体化

✔ 为什么获取句柄要小心 Flags

FILE_FLAG_OPEN_REPARSE_POINT
  • 避免系统认为你要读数据

  • 只是“操作占位符元信息”


✔ 为什么 CfOpenFileWithOplock 很重要

  • 它明确告诉系统:
    这是 Provider 操作,不是用户访问

  • 避免错误触发 FetchData


八、一句话总结

占位符不是“可选项”,而是 CFAPI 的根基。

没有占位符:

  • Explorer 不可见

  • FetchData 不触发

  • 同步状态失效

  • CFAPI 退化成普通文件 IO

九、占位符(Placeholder)操作类

使用 C# .NET8

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0-windows10.0.17763.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.Windows.CsWinRT" Version="2.2.0" /> <PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Vanara.PInvoke.CldApi" Version="4.2.1" /> <PackageReference Include="Vanara.PInvoke.SearchApi" Version="4.2.1" /> </ItemGroup> </Project>
using Serilog; using System.ComponentModel; using System.Runtime.InteropServices; using Vanara.PInvoke; using static Vanara.PInvoke.CldApi; using static Vanara.PInvoke.Kernel32; namespace CfapiSync { public class Placeholder { public static bool Create(string fileId , string baseDirectoryPath, string relativeFileName, CF_FS_METADATA fsMetadata) { bool isDirectory = fsMetadata.BasicInfo.FileAttributes.HasFlag(FileFlagsAndAttributes.FILE_ATTRIBUTE_DIRECTORY); var cloudEntry = new CF_PLACEHOLDER_CREATE_INFO { FileIdentity = Marshal.StringToCoTaskMemUni(fileId), FileIdentityLength = (uint)(fileId.Length * Marshal.SizeOf(typeof(char))), RelativeFileName = relativeFileName, FsMetadata = fsMetadata, Flags = CF_PLACEHOLDER_CREATE_FLAGS.CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, }; if (isDirectory) { cloudEntry.Flags |= CF_PLACEHOLDER_CREATE_FLAGS.CF_PLACEHOLDER_CREATE_FLAG_DISABLE_ON_DEMAND_POPULATION; cloudEntry.FsMetadata.FileSize = 0; } var entitys = new CF_PLACEHOLDER_CREATE_INFO[] { cloudEntry }; var res = CfCreatePlaceholders(baseDirectoryPath, entitys, 1, CF_CREATE_FLAGS.CF_CREATE_FLAG_NONE, out uint entresProcessed); if (res.Succeeded) { Log.Information($"Placeholder-> Create : {relativeFileName} "); return true; } if(res.Code == 183) { Log.Warning($"Placeholder-> Create -> 已经存在 {relativeFileName} "); Convert(fileId, System.IO.Path.Combine(baseDirectoryPath, relativeFileName)); return false; } if (!res.Succeeded) { Log.Error($"Placeholder-> Create : {relativeFileName} 创建失败! {res}"); return false; } return true; } public static bool Convert(string fileId, string filePath) { var hFile = CreateFile(filePath, Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Log.Warning("Placeholder-> Convert: 文件无效!"); return false; } var fileIdentity = Marshal.StringToCoTaskMemUni(fileId); var fileIdentityLength = (uint)(fileId.Length * Marshal.SizeOf(typeof(char))); // CF_CONVERT_FLAG_NONE 什么都不显示 // CF_CONVERT_FLAG_MARK_IN_SYNC 与云同步 绿色对号, // CF_CONVERT_FLAG_DEHYDRATE 文件操作后 变为云文件 var res = CfConvertToPlaceholder(hFile, fileIdentity, fileIdentityLength, CF_CONVERT_FLAGS.CF_CONVERT_FLAG_MARK_IN_SYNC, out long ConvertUsn, 0); hFile.Dispose(); if (res.Succeeded) { Log.Information($"Placeholder-> Convert : {filePath} "); return true; } else { Log.Error($"Placeholder-> Convert : {filePath} 转换失败! {res}"); return false; } } // 将占位符恢复为常规文件,去除所有特殊特征,例如重分析标记、文件标识等。 public static bool Revert( string filePath) { var hFile = CreateFile(filePath, Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); var res = CfRevertPlaceholder(hFile, CF_REVERT_FLAGS.CF_REVERT_FLAG_NONE,0); if (res.Succeeded) { Log.Information($"Placeholder-> Revert : {filePath} "); } else { Log.Error($"Placeholder-> Revert : {filePath} 转换失败! {res}"); } return true; } public static bool Update(string filePath, CF_FS_METADATA fsMetadata) { var hFile = CreateFile(filePath, Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); // HRESULT hr = CfOpenFileWithOplock(filePath,CF_OPEN_FILE_FLAGS.CF_OPEN_FILE_FLAG_DELETE_ACCESS, out SafeHCFFILE hFile); //if (hr.Failed) //{ // Log.Error($"Placeholder-> Update : {filePath} 更新失败!"); // return false; //} IntPtr infoBuffer = IntPtr.Zero; try { CF_PLACEHOLDER_INFO_CLASS infoClass = CF_PLACEHOLDER_INFO_CLASS.CF_PLACEHOLDER_INFO_STANDARD; uint bufferLength = (uint)Marshal.SizeOf<CF_PLACEHOLDER_STANDARD_INFO>() + 4096; infoBuffer = Marshal.AllocHGlobal((int)bufferLength); var fileHandle = hFile.DangerousGetHandle(); var hr = CfGetPlaceholderInfo( fileHandle, infoClass, infoBuffer, bufferLength, out uint returnedLength); if (hr.Failed) { Log.Error($"Placeholder-> Update : {filePath} 更新失败!"); return false; } var standardInfo = Marshal.PtrToStructure<CF_PLACEHOLDER_STANDARD_INFO>(infoBuffer); // 2. FileIdentity 必须有效 if (standardInfo.FileIdentity == null || standardInfo.FileIdentity.Length == 0 || standardInfo.FileIdentityLength == 0) { throw new InvalidOperationException("Invalid FileIdentity."); } GCHandle handle = GCHandle.Alloc(standardInfo.FileIdentity, GCHandleType.Pinned); try { IntPtr fileIdentityPtr = handle.AddrOfPinnedObject(); System.Int64 usn =0; hr = CfUpdatePlaceholder( fileHandle, // 句柄无效 fsMetadata, fileIdentityPtr, standardInfo.FileIdentityLength, null, 0, CF_UPDATE_FLAGS.CF_UPDATE_FLAG_VERIFY_IN_SYNC, ref usn ); if (hr.Failed) { Log.Error($"Placeholder-> Update : {filePath} 更新失败!{hr}"); return false; } } finally { handle.Free(); } Log.Information($"Placeholder-> Update : {filePath}"); return true; } finally { if (infoBuffer != IntPtr.Zero) Marshal.FreeHGlobal(infoBuffer); hFile.Dispose(); } } /// <summary> /// 判断路径否是 Cloud Placeholder,以及它的脱水与同步状态 /// </summary> public static CF_PLACEHOLDER_STATE GetStatus(string filePath, out bool isPlaceholder, out bool isDehydrated ,out bool isSynced ) { isPlaceholder = false; isDehydrated = false; isSynced = false; var hFile = CreateFile( filePath, 0, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Log.Error($"Placeholder-> GetStatus : {filePath},文件无效!"); return CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_INVALID; } try { int size = Marshal.SizeOf<FILE_ATTRIBUTE_TAG_INFO>(); IntPtr infoBuffer = Marshal.AllocHGlobal(size); try { bool ok = GetFileInformationByHandleEx(hFile, FILE_INFO_BY_HANDLE_CLASS.FileAttributeTagInfo, infoBuffer, (uint)size); if (!ok) { Log.Error($"Placeholder-> GetStatus -> GetFileInformationByHandleEx -> {filePath} !"); return CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_INVALID; } CF_PLACEHOLDER_STATE state = CfGetPlaceholderStateFromFileInfo(infoBuffer, FILE_INFO_BY_HANDLE_CLASS.FileAttributeTagInfo); if (state == CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_INVALID) throw new InvalidOperationException("Invalid placeholder state"); isPlaceholder = (state & CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_PLACEHOLDER) != 0; isDehydrated = (state & CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_PLACEHOLDER) != 0 && (state & CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_PARTIALLY_ON_DISK) != 0; isSynced = (state & CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_IN_SYNC) != 0; Log.Information($"Placeholder-> GetStatus : {filePath},state->{state}!"); return state; } finally { Marshal.FreeHGlobal(infoBuffer); } } finally { hFile.Dispose(); } } /// <summary> /// 设置占位符文件或文件夹的同步状态。 /// </summary> /// <param name="filePath">文件地址</param> /// <param name="syncOngoing">同步进行中</param> /// <returns></returns> public static bool SetStatus(string filePath, bool syncOngoing) { var hFile = CreateFile( filePath, 0, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Log.Error($"Placeholder-> GetStatus : {filePath},文件无效!"); return false; } CF_IN_SYNC_STATE inSyncState = syncOngoing ? CF_IN_SYNC_STATE.CF_IN_SYNC_STATE_IN_SYNC : CF_IN_SYNC_STATE.CF_IN_SYNC_STATE_NOT_IN_SYNC; CF_SET_IN_SYNC_FLAGS inSyncFlags = CF_SET_IN_SYNC_FLAGS.CF_SET_IN_SYNC_FLAG_NONE; long inSyncUsn = 0; var res = CfSetInSyncState(hFile, inSyncState, inSyncFlags, ref inSyncUsn); hFile.Dispose(); if (res.Succeeded) { Log.Information($"Placeholder-> SetStatus : {filePath},state->{syncOngoing}!"); return true; } else { Log.Error($"Placeholder-> SetStatus : {filePath},state->{syncOngoing}!"); return false; } } /// <summary> /// 获取占位符信息 /// </summary> public static CF_PLACEHOLDER_STANDARD_INFO GetInfo( string filePath) { var hFile = CreateFile( filePath, 0, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Log.Error($"Placeholder-> GetInfo : {filePath},文件无效!"); throw new Win32Exception(Marshal.GetLastWin32Error()); } try { var infoClass = CF_PLACEHOLDER_INFO_CLASS.CF_PLACEHOLDER_INFO_STANDARD; var length = 4096; var buffer = Marshal.AllocHGlobal( length); // 正式获取信息 HRESULT hr = CfGetPlaceholderInfo( hFile, infoClass, buffer, (uint)length, out uint returnedLength); if (hr.Failed) { Log.Error($"Placeholder-> GetInfo : 获取失败 {hr}"); Marshal.ThrowExceptionForHR(hr.Code); } var info = Marshal.PtrToStructure<CF_PLACEHOLDER_STANDARD_INFO>(buffer); Log.Information($"Placeholder-> GetInfo : 获取成功{info}"); Marshal.FreeHGlobal(buffer); return info; } finally { hFile.Dispose(); } } //CF_PIN_STATE_PINNED 固定文件,用户本地一定有完整文件内容 //CF_PIN_STATE_UNPINNED 取消固定,文件不需要始终保留本地内容,可以节省磁盘空间 //CF_PIN_STATE_EXCLUDED 排除同步,占位符永远不被同步到云端 //CF_PIN_STATE_INHERIT 继承, 状态继承自父目录 //CF_PIN_STATE_UNSPECIFIED 未指定, 系统自由管理占位符内容 public static bool SetPinState(string path, CF_PIN_STATE state) { var hFile = CreateFile(path, Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT |FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Console.WriteLine("Failed to open file."); return false; } var res = CfSetPinState(hFile, state, CF_SET_PIN_FLAGS.CF_SET_PIN_FLAG_NONE, IntPtr.Zero); hFile.Dispose(); if (res.Succeeded) { Log.Information($"Placeholder-> SetPinState({state}) : {path} "); return true; } else { Log.Warning($"Placeholder-> SetPinState({state}) : {path} , 错误 {res} "); return false; } } } }

Create —— 创建占位符

根据远端文件或目录的元数据,在本地 NTFS 中创建一个云文件占位符。
创建后文件立即可见但不包含实际数据,不占用磁盘空间。


Convert —— 转换为占位符

将一个已存在的普通文件或目录转换为云同步占位符。
常用于接管本地已有文件,使其纳入云同步体系。


Revert —— 恢复为普通文件

移除文件的云占位符特性,将其还原为标准 NTFS 文件或目录。
用于解除云同步绑定或回滚云文件状态。


Update —— 更新占位符元数据

更新占位符的文件大小、时间戳、属性等元数据信息。
用于同步远端变更,不涉及文件数据读写。


GetStatus —— 获取占位符状态

判断指定路径是否为云占位符,并获取其实体化与同步状态。
用于决定是否需要下载、脱水或更新同步标识。


SetStatus —— 设置同步状态

显式设置占位符的“已同步 / 未同步”状态。
用于控制 Explorer 中的同步完成图标显示。


GetInfo —— 获取占位符详细信息

读取占位符的标准 CFAPI 信息结构,包括标识、状态和标志位。
主要用于调试、审计或高级同步逻辑判断。


SetPinState —— 固定 / 取消固定文件

设置占位符文件的固定策略,控制是否必须常驻本地数据。
用于支持“始终保留本地副本”或“按需下载”的用户行为。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 7:02:34

Windows下Miniconda激活失败?PowerShell权限设置详解

Windows下Miniconda激活失败&#xff1f;PowerShell权限设置详解 在搭建AI开发环境时&#xff0c;你是否曾遇到这样的场景&#xff1a;刚装好Miniconda&#xff0c;满怀期待地打开PowerShell&#xff0c;输入conda activate myenv&#xff0c;结果却弹出一串红色错误&#xff1…

作者头像 李华
网站建设 2026/4/18 8:25:00

8、Linux与Windows环境下的软件应用与数据库管理

Linux与Windows环境下的软件应用与数据库管理 1 办公软件相关问题 在处理办公文件时,会遇到一些特定问题。例如,在使用某些软件处理公式和特殊功能时,可能会出现状况。长且复杂的公式可能会有问题,要特别留意绝对单元格引用以及依赖计算顺序的操作。同时,数据验证、帮助…

作者头像 李华
网站建设 2026/4/18 14:42:02

百度搜索不到的秘籍:国内高速下载Qwen3-14B模型的方法

百度搜索不到的秘籍&#xff1a;国内高速下载Qwen3-14B模型的方法 在AI应用加速落地的今天&#xff0c;越来越多企业开始尝试将大语言模型集成到自有系统中。然而&#xff0c;一个看似简单的问题却常常卡住项目进度——如何稳定、快速地下载像 Qwen3-14B 这样的开源大模型&…

作者头像 李华
网站建设 2026/4/18 7:01:06

教你使用服务器搭建优雅的实时热门新闻阅读工具 NewsNow

现在获取信息最大的痛点,不是“没内容”,而是内容太多、太杂、太分散。 每天想看看热点,你可能需要来回切换: 微博热搜 知乎热榜 抖音热点 科技新闻站 GitHub Trending Hacker News 财经、国际新闻平台 结果就是: 👉 打开了一堆 App 👉 被算法推着刷 👉 真正…

作者头像 李华
网站建设 2026/4/17 17:49:57

三菱FX5U与台达DT330温控器通讯及输出启停控制实战

三菱FX5U与台达DT330温控器通讯程序输出启停控制(SL5U-9)功能&#xff1a;通过三菱FX5U本体485口&#xff0c;结合触摸屏网口&#xff0c;实现对台达DT330温控器 设定温度&#xff0c;读取温度&#xff0c;输出启停控制。 反应灵敏&#xff0c;通讯稳定可靠。器件&#xff1a;三…

作者头像 李华
网站建设 2026/4/18 10:18:32

如何批量导出LobeChat中的对话记录?数据迁移策略

如何批量导出LobeChat中的对话记录&#xff1f;数据迁移策略 在今天&#xff0c;越来越多的开发者和企业用户开始依赖像 LobeChat 这样的现代化 AI 聊天界面来对接大语言模型&#xff08;LLM&#xff09;。它不仅界面优雅、扩展性强&#xff0c;还支持多种本地与云端模型接入。…

作者头像 李华