使用 Electron 开发聊天记录管理桌面应用

代码仓库见 github.com/wangziao9/directory-of-dialogues,不定期维护。

动机:聊天会话的笔记管理

线性倒序列表的普遍性

多数 AI 聊天助手,不论是网页还是桌面应用,对聊天会话的管理功能大体都遵循 ChatGPT 的惯例,提供一个线性、时间倒序的会话列表。这相当于一个 LRU cache,在大部分情况下都很实用。问题是,用户无法以任何形式组织它们(文件夹?画布?),也无法直接搜索其中的内容。

尽管 ChatGPT 可以导出数据,获得一个巨大的 json 文件和一个 html 文件供你浏览、搜索聊天记录,但当你在浏览器中打开 html 文件后,获得的仍然是一个逆时间顺序的聊天记录列表。

会话作为笔记

就我个人的使用需求来看,一个会话的生命周期可能很长,比如专门使用一个会话

  • 进行 VPS 的配置相关的聊天,今天问问如何部署一个应用,一周后问问这个应用的技术原理,一个月后问问上个月部署的应用出错了怎么排错
  • 专门聊一个技术方向的问题,比如 Web 开发,有问题都在该会话里问
  • 记录做饭:询问菜谱里不清楚的地方,拍照询问厨具怎么使用,最后记录成品质量反思成败原因

长生命周期、专门化的会话的好处是不仅 AI 可以利用上文的语境给出更针对性的建议,你自己也可以参考之前的问答,这个会话就发挥了笔记的作用。

线性倒序列表的不足

既然把会话作为笔记,唯一的索引方式是“线性倒序列表”就很糟糕了。查找一份笔记需要 O(N) 线性扫描 整个列表。

此外,当开启了低优先级、短生命周期的会话后,这些会话会占用列表中的位置,让你的“笔记”更难找到。

传统的电子笔记被管理在文件夹的层级结构中,只要结构合理,查找是 O(log N) 的,结构清晰、查找高效。计算机自带的文件系统就提供了这样一个层次结构,我们的客户端只要支持将聊天会话(以 JSON 格式)导出到文件系统 或 从文件系统导入,就解决了线性倒序列表的不足。

JavaScript 编程难点

这部分主要参考菜鸟教程

JavaScript浏览器脚本编程 vs Node.js 编程

JavaScript 有两种使用场景:

  1. 作为一种插入 HTML 的脚本代码,在浏览器中运行(前端)。常见的操作包括操作 DOM 文件对象模型、处理时间、向服务器发出请求(AJAX)等。
  2. 依托特殊的运行时(Node.js),运行在服务器端(后端)。常见的操作包括文件系统操作、网络请求等。

一般来说,前者称为 JavaScript 编程,后者称为 Node.js 编程。这次选择 Electron 作为桌面应用的开发框架,就是要在桌面端同时定义“前端”和“后端”的行为。

Node.js module 模块

Node.js 模块提供了 Node.js 文件相互调用的方式。一个最简单的例子见 Runoob

== main.js ==
var hello = require('./hello');
hello.world();

== hello.js ==
exports.world = function() {
  console.log('Hello World');
}

Node.js 模块包括原生模块(如 fs, http)、本地模块(见上面的例子)、第三方模块(如 electron)等。

2015 年发布的 ECMAScript 2015 (简称 ES6) 标准用 ES Modules 取代了 CommonJS 的模块系统,使用 import export 关键词导入导出(而非 require 和 module.exports)。然而,Node.js REPL (交互式解释器) 中仍不支持 import。

深入 Javascript 运行时

Javascript 运行时本质上是单线程的。它维护一个事件循环(Event loop),负责执行代码、处理所有待处理的事件。

Javascript 使用任务(Task)和微任务(Microtask)来调度异步事件。处理事件循环的顺序如下:1) 同步代码,2)执行所有微任务队列中的任务,直到微任务队列为空,3)执行一个任务队列中的任务。例如:

console.log('Start');

setTimeout(() => {
  console.log('Timeout Task');  // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('Promise Microtask');  // 微任务
});

console.log('End');

注意到 Promise,await 等操作会注册微任务,setTimeout 会注册任务。因此,输出的顺序为:

Start
End
Promise Microtask
Timeout Task

Javascript 异步编程

同步函数的本质是阻塞,异步函数的本质是不阻塞并回调。

回调函数

回调函数的思想很简单,通过回调函数定义异步任务结束后干什么

// Example: AJAX - Asynchronous JavaScript and XML
var xhr = new XMLHttpRequest();
 
xhr.onload = function () {
    document.getElementById("log").innerHTML += " xhr.onload() ";
    document.getElementById("demo").innerHTML=xhr.responseText;
}
 
// 发送异步 GET 请求
xhr.open("GET", "https://www.runoob.com/try/ajax/ajax_info.txt", true);
xhr.send();
document.getElementById("log").innerHTML += " done ";

在 log 元素中,done 比 xhr.onload() 位置靠前,说明 xhr.send() 没有阻塞执行。

Promise

Promise 的构造函数包含一个“起始函数”作为参数。起始函数是同步的,在构造 Promise 时会立刻执行,并接受 resolve 和 reject 两个参数,如果执行成功,调用 resolve 传递执行成功的结果,反之调用 reject 调用执行失败的结果。

异步之处在于,resolve 和 reject 会改变 Promise 的状态,并触发 then、catch 中传入的回调函数。

const promise = new Promise((resolve, reject) => {
  // 异步操作
  setTimeout(() => {
    if (Math.random() < 0.5) {
      resolve('success');
    } else {
      reject('error');
    }
  }, 1000);
});
 
promise.then(result => {
  console.log(result);
}).catch(error => {
  console.log(error);
});

这段程序会输出 error 或 success。

Q:既然起始函数是同步的,为什么可以在它的函数体中调用异步的 setTimeout?
A: 正如前面所说的,同步函数的本质是阻塞,异步函数的本质是不阻塞并回调。没有什么规定同步函数里不能调用异步函数。恰恰相反,如果需要在同步函数 f 中进行一个耗时较长的操作,我们宁愿把它写成异步函数而不是同步函数,因为这样可以减少 f 对主线程的阻塞。

Async Function

ECMAScript 2017 (ES8) 定义了异步函数 (async function) 标准,受到广泛的浏览器支持。

调用 async function 会返回 Promise 对象。

可以通过 await 关键字调用返回 Promise 对象的函数。在函数 f 中使用 await 关键词调用 g 的语义为,将控制流转移给 g,并将函数 f 在 await 语句后面的语句注册为微任务,直到 g 运行结束后才被运行。

await 关键字只能在 async function 的函数体内部使用。这是因为,同步函数语义必须保证函数体内顺序执行所有语句,控制流不能被打断。

综合:Javascript异步编程面试题

题目:下面程序中的调试语句按照什么顺序输出?为什么?

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 start')
    await async3()
    console.log('async2 end')
}
async function async3() {
    console.log('async3')
}
console.log('script start')
setTimeout(() => {
    console.log('setTimeout')
}, 0);
async1()
new Promise(resolve => {
    console.log('promise1')
    resolve()
})
    .then(() => {
        console.log('promise2')
    })
console.log('script end')

首先顺序执行所有语句:

  • 首先输出 “script start”
  • setTimeout 注册了一个宏任务
  • 执行 async1() 函数,为此进入 async1 函数体内,输出 “async1 start”。遇见 await 关键字,将未执行的语句注册为微任务,转移控制流给 async 2
  • 执行 async2() 函数,输出 “async2 start”。遇见 await 关键字,将未执行的语句注册为微任务,转移控制流给 async 3
  • async3 输出 “async3”,随后控制流返回最底层
  • 构造一个 Promise,起始函数立即执行,输出 “promise 1″,将 then 中的函数注册为微任务
  • 到了结尾,输出 “script end”

然后清空微任务队列:

  • async2 未完成,async1 函数的剩余部分无法执行;async3 已完成,async2 函数的剩余部分执行,输出 “async2 end”
  • Promise 顺利返回的回调函数执行,输出 “promise 2”
  • 此时队列仍未清空,async2 已完成,async1 函数的剩余部分执行,输出 “async1 end”

最后执行一个宏任务队列中的元素:

  • 输出 “setTimeout”

Electron 应用的结构

Electron 的进程模型

Electron 的进程模型继承自 Chrome 的进程模型,即每个标签页使用独立的进程来渲染,从而实现故障的隔离。Chrome 浏览器用一个总管进程管理所有标签页渲染进程。

Electron 应用开发者控制两个进程:main 和 renderer,分别对应总管进程和渲染进程。主进程的运行环境是 Node.js,可以用 require 引入模块;渲染进程就如同执行在一个浏览器中,并没有 Node.js 的全部环境,不能引入模块。

渲染进程初始化时会执行 preload.js,此时有更高的权限,可以暴露更多接口给 renderer.js。

Electron 的进程间通信

main.js 和 renderer.js 之间的通信通过预加载脚本 proload.js 提供。具体而言,就是使用 contextbridge 注册暴露给 renderer.js 的函数。最简单的例子是:

const marked = require('marked');
contextBridge.exposeInMainWorld('api', {
  render: (markdown) => {return marked(markdown)},
  f1: (...) => {...},
  f2: (...) => {...}
}

有回调函数会让事情更复杂,而负责 electron 主进程 main.js 和渲染进程 renderer.js 间通信的 ipcRenderer 恰好是利用回调函数工作的。

主进程会利用监听一系列信道(channel),并为其注册回调函数,这可能让进程间通信更复杂。

打包生成各个平台上的应用

Electron 应用通过 Electron Forge 来打包生成应用。摘抄自教程,

npm install --save-dev @electron-forge/cli
npx electron-forge import

这一步会修改 package.json,package-lock.json 并创建 forge.config.js。然后,打开修改后的 package.json,手动填写 author 和 description 字段。最后,

npm run make

Electron Forge 会默认生成适用于你当前平台的应用。要跨平台构建,需要更改 package.json 的配置

在 out 文件夹下可以找到适用你当前平台的应用程序,可以尝试复制它到其他文件夹,会发现它缺少 dll 无法工作,说明它需要同文件夹下的 dll 等资源(包括源文件夹里 .env 文件提供的 OpenAI API Key)才能工作。

可以通过如下方式将你的应用放到 Windows 开始文件夹(屏幕左下角打开)下:创建引用程序的快捷方式,打开 C:\ProgramData\Microsoft\Windows\Start Menu\Programs,然后将快捷方式粘贴过去。

Web 开发中的踩坑

输入框

Q: 如何让用户输入文本的 textarea 框占满宽度?
A: 父元素使用 flex 布局 display: flex,本 textarea 元素使用 flex-grow: 1 占满剩余空间。

Q: 文本框 textarea 如何随着用户的输入而变高?
A: 仅仅使用 css 是不够的,需要使用 js 监听每次输入,调整高度:

// Adjust textarea height based on content
textarea.addEventListener('input', function () {
    this.style.height = 'auto'; // Reset the height
    this.style.height = Math.min(this.scrollHeight, 10 * 1.5 * 16) + 'px'; // Limit to 10 lines
});

这里关键的逻辑是将高度手动设置为元素的滚动高度(scrollheight)。如果没有这一行,textarea 不会调整高度。

如何指定 HTML 元素

通过在网页浏览器(包括 Electron 窗口)中打开开发者工具 developer tools,选择console,就可以访问 renderer.js 中的全局变量了。或者,从 document 变量出发,访问 DOM(文档对象模型),例如 document.children[0].children[1]...

在 console 中输入对应 HTML 元素的变量后,开发者工具会自动显示对象的类型,高亮网页的对应区域,并提供补全提示。通过这种方法,可以试出如何在 JS 代码中访问到你想修改的元素,例如

api.onStreamChunk((chunk) => {
    console.log("chunk = ", chunk);
    // Append chunk to the output element
    c = messageList.children;
    c[c.length-1].children[0].innerText += chunk;
});

罕见 bug: 设置回调函数的时机

关于如何让 OpenAI chat completion 的 Streaming 接口反映到应用上(动态显示回答生成过程),自动代码生成工具给出的方案是

const outputElement = document.getElementById('output'); // Assuming there's an element with id 'output'

async function main() {
  const messages = [{ role: "user", content: "Say this is a test" }];

  // Start the streaming process
  await window.api.startStream(messages);

  // Handle each chunk of data as it arrives
  window.api.onStreamChunk((chunk) => {
    outputElement.textContent += chunk; // Append chunk to the output element
  });

  // Handle the end of the stream
  window.api.onStreamEnd(() => {
    console.log('Stream ended');
  });
}

// Call the main function on page load or button click
main();

这个方案中,回答不会出现在网页上,onStreamChunk 和 onStreamEnd 的回调函数不会被调用。问题出在哪里?

这个 await 就很要命,在 startStream 执行完毕返回之前,main 函数的余下部分就不会执行。然而,startStream 刚好需要在接受所有的 chunk(并逐一发送 stream-chunk),并在结束后发送 stream-end 之后才会返回。因此,在 main 进程在发送 stream-chunk 时,还没有注册相应的回调函数,自然无法处理了。

教训有二:一来在用 await 时要知道自己在干什么,二来注册回调函数的语句最好放在函数之外,如文件的顶端,尤其不要放在其它回调函数体 f 中,这会让代码的逻辑混乱(每次调用 f,都要注册一遍?),如下面所示

addbutton.addEventListener('click', async () => {
  ...
  api.onStreamChunk((chunk) => {
    ...
  })
})

Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *