海浪海之言 發表於 2026-4-24 07:33:00

WPF 结合本地 Ollama 千问多模态实现离线屏幕使用记录工具

<p>本文内容由 AI 辅助编写</p>
<h2 id="界面">界面</h2>
<p>以下是在我电脑上跑出来的效果图</p>

<p><img src="https://img2024.cnblogs.com/blog/1080237/202604/1080237-20260424073249725-1450716674.png" alt="" loading="lazy"></p>
<h2 id="背景">背景</h2>
<p>我之前一直想统计自己每天的时间分配,清晰了解大部分时间花在哪些应用、哪些任务上,但市面上的同类工具要么需要上传截图到云端,隐私得不到保障,要么只能统计前台应用的驻留时长,没办法知道具体在操作什么内容。同时为了测试本地多模态大模型的能力是否足够成熟,于是就做了这么一款完全离线的屏幕记录工具。</p>
<h2 id="技术细节">技术细节</h2>
<ol>
<li><strong>截图能力</strong>:采用 Windows.Graphics API 实现截图,相比传统 GDI 截图性能更好,资源占用极低,再加上 10 秒截一次,对整机性能几乎没有影响,天然支持多屏幕同时适配。</li>
<li><strong>大模型</strong>:使用本地 Ollama 部署的 qwen3-vl:8b 多模态模型,完全离线运行,不需要把截图传到任何第三方服务器,隐私完全可控,8B 参数的模型在普通消费级显卡上就能流畅运行,一次解读仅需 2-3 秒。</li>
<li><strong>上下文逻辑</strong>:每次解读时会带上最近 10 张截图的历史解读内容,保证上下文连贯性,同时明确要求模型优先信任当前截图的内容,避免历史内容误导解读结果。</li>
</ol>
<p>截图能力详细请参阅: dotnet 控制台调用 Windows.Graphics 实现屏幕截图功能</p>

<p>调用 Ollama 的方法请参阅: 通过 OllamaSharp 对接 Microsoft Agent Framework 的方法</p>

<h2 id="核心实现">核心实现</h2>
<h3 id="1-大模型解读服务">1. 大模型解读服务</h3>
<p>解读服务的核心是 Prompt 设计,我专门限制了模型的输出规则,避免返回泛泛而谈的无效内容,要求必须明确指出当前打开的应用、文档/页面名称、正在执行的操作,核心代码如下:</p>
<pre><code class="language-csharp">public ScreenshotAnalysisService(Uri ollamaEndpoint, string modelId)
{
    var ollamaApiClient = new OllamaApiClient(ollamaEndpoint, modelId);
    _agent = new ChatClientAgent(
      ollamaApiClient,
      instructions:
      """
      You analyze desktop screenshots.
      Reply in Chinese with 1-2 sentences.
      Name the visible application, page, or document whenever it can be identified.
      Describe the current task and include only details that are actually visible.
      If a detail is unclear, say so instead of guessing.
      """);
}
</code></pre>
<p>以上提示词由 GitHub Copilot 编写</p>
<p>调用时需要传入当前截图的字节流、截图时间和最近 10 条历史解读上下文,核心调用逻辑如下:</p>
<pre><code class="language-csharp">public async Task&lt;string&gt; AnalyzeAsync(
    string imagePath,
    DateTimeOffset capturedAt,
    IReadOnlyCollection&lt;SnapshotAnalysisContext&gt; recentContexts,
    CancellationToken cancellationToken = default)
{
    var imageBytes = await File.ReadAllBytesAsync(imagePath, cancellationToken);
    var message = new ChatMessage
    {
      Role = ChatRole.User,
      Contents =
      [
            new TextContent(BuildPrompt(capturedAt, recentContexts)),
            new DataContent(imageBytes, GetImageMimeType(imagePath))
      ]
    };
    var response = await _agent.RunAsync(message);
    return response.Text?.Trim() ?? "模型没有返回可用的解读内容。";
}
</code></pre>
<p>这里的 <code>BuildPrompt</code> 方法会把最近 10 条历史记录按时间顺序拼接到提示词中,同时明确告知模型历史内容仅作为参考,优先级低于当前截图。</p>
<h3 id="2-定时截图循环">2. 定时截图循环</h3>
<p>默认 10 秒截图一次,支持多屏幕依次处理,避免同时截图造成性能波动,核心循环逻辑如下:</p>
<pre><code class="language-csharp">private async Task RunCaptureLoopAsync(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
      foreach (var display in _screenSnapshotProvider.GetDisplays())
      {
            var stopwatch = Stopwatch.StartNew();
            await CaptureAndAnalyzeDisplayAsync(display, cancellationToken);
            // 保证两次截图间隔至少10秒
            var remainingDelay = MinimumCaptureInterval - stopwatch.Elapsed;
            if (remainingDelay &gt; TimeSpan.Zero)
            {
                await Task.Delay(remainingDelay, cancellationToken);
            }
      }
    }
}
</code></pre>
<p>如果有多块屏幕,会依次处理每块屏幕的截图和解读,处理完所有屏幕后再等待剩余的间隔时间,避免截图频率过高。</p>
<h3 id="3-存储自动清理逻辑">3. 存储自动清理逻辑</h3>
<p>所有截图默认存到 <code>%LocalAppData%\SnapkeboyearheNarjairfiru\Snapshots</code> 目录下,截图存 PNG 格式,解读结果存 XML 格式,当目录总大小超过 1G 时,会自动删除最旧的截图文件,但保留 XML 解读记录,既不占用过多磁盘空间,也能保留所有历史解读内容,核心清理代码如下:</p>
<pre><code class="language-csharp">private async Task&lt;StorageCleanupResult&gt; CleanupStorageIfNeededAsync(CancellationToken cancellationToken)
{
    return await Task.Run(() =&gt;
    {
      var directoryInfo = new DirectoryInfo(StorageFolderPath);
      var totalSize = directoryInfo.EnumerateFiles().Sum(file =&gt; file.Length);
      if (totalSize &lt;= 1024L * 1024 * 1024) // 1G存储上限
      {
            return StorageCleanupResult.Empty;
      }
      // 按创建时间升序,优先删除最旧的截图
      var filesToDelete = directoryInfo.EnumerateFiles()
            .Where(file =&gt; SnapshotImageExtensions.Contains(file.Extension))
            .OrderBy(file =&gt; file.CreationTimeUtc)
            .ToList();
      long releasedBytes = 0;
      int deletedCount = 0;
      foreach (var file in filesToDelete)
      {
            if (totalSize &lt;= 1024L * 1024 * 1024) break;
            var length = file.Length;
            file.Delete();
            totalSize -= length;
            releasedBytes += length;
            deletedCount++;
      }
      return new StorageCleanupResult(deletedCount, releasedBytes);
    }, cancellationToken);
}
</code></pre>
<h3 id="4-界面实现">4. 界面实现</h3>
<p>界面采用 WPF 开发,使用虚拟化 ListView 展示历史记录,默认最多显示 100 条最近记录,也可以手动加载更多历史,支持暂停/恢复截图、打开存储目录等功能,每个条目显示截图缩略图、截图时间、解读内容、文件路径,如果截图已经被清理,会显示「截图已清理,仅保留 XML 解读」的提示。</p>
<h2 id="使用注意事项">使用注意事项</h2>
<ol>
<li>首先需要安装 Ollama,官网地址:https://ollama.com/,安装完成后执行 <code>ollama pull qwen3-vl:8b</code> 拉取千问3多模态模型,如果你的显存小于 8G,可以拉取 <code>qwen3-vl:4b</code> 版本,占用显存更小,仅性能略降。</li>
<li>代码中的 <code>OllamaEndpoint</code> 需要改成你自己的 Ollama 服务地址,本地部署默认是 <code>http://localhost:11434</code>,如果部署在局域网其他设备上也可以填写对应的局域网地址,依然完全内网运行不会泄露数据。</li>
<li>截图间隔可以自行修改 <code>MinimumCaptureInterval</code> 常量,默认 10 秒,觉得太频繁可以改成 30 秒或者 1 分钟,进一步降低资源占用。</li>
<li>存储上限可以自行修改 <code>StorageLimitBytes</code> 常量,默认 1G,不够用可以改成更大的值。</li>
</ol>
<p>我自己使用了半天,全程后台运行几乎感知不到性能影响,下班之后(不存在)翻一遍历史记录就能清晰看到一天的时间分配,非常方便,且全程离线完全不用担心隐私泄露问题。还可以在此基础上扩展统计功能,比如自动统计每天花在每个应用上的时长、生成周日报等。</p>
<h2 id="代码">代码</h2>
<p>本文以上代码放在 github 和 gitee 上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快</p>
<p>先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码</p>
<pre><code>git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin f71fb96117919accc639260f122c819cfbc2890e
</code></pre>
<p>以上使用的是国内的 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码</p>
<pre><code>git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin f71fb96117919accc639260f122c819cfbc2890e
</code></pre>
<p>获取代码之后,进入 SemanticKernelSamples/SnapkeboyearheNarjairfiru 文件夹,即可获取到源代码</p>


</div>
<div id="MySignature" role="contentinfo">
    <p>博客园博客只做备份,博客发布就不再更新,如果想看最新博客,请访问 https://blog.lindexi.com/</p>

<p>如图片看不见,请在浏览器开启不安全http内容兼容</p>

<img alt="知识共享许可协议" style="border-width: 0" src="https://licensebuttons.net/l/by-nc-sa/4.0/88x31.png"><br>本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。欢迎转载、使用、重新发布,但务必保留文章署名[林德熙](https://www.cnblogs.com/lindexi)(包含链接:https://www.cnblogs.com/lindexi ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请与我[联系](mailto:lindexi_gd@163.com)。<br><br>
来源:https://www.cnblogs.com/lindexi/p/19919367
頁: [1]
查看完整版本: WPF 结合本地 Ollama 千问多模态实现离线屏幕使用记录工具