代码仓库见 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 有两种使用场景:
- 作为一种插入 HTML 的脚本代码,在浏览器中运行(前端)。常见的操作包括操作 DOM 文件对象模型、处理时间、向服务器发出请求(AJAX)等。
- 依托特殊的运行时(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) => {
...
})
})
Leave a Reply