大模型时代我们怎么玩Emacs:2. 中文按词移动和删除

· 2710字 · 6分钟

1 前言 🔗

在大模型时代,一切皆有可能。对于 Emacs 同样如此,大模型拉低学习了 Emacs 的门槛,我们通过大模型可以做到以前我们敢想不敢做,或敢做没时间做,或有时间不会做的事。

今天继续这个新的系列,跟大家分享在大模型时代,我们可以如何“玩” Emacs,我们应该怎么用大模型来满足自己对 Emacs 使用的需求。

2 大模型 🔗

这个系列的几个案例,均基于 OpenAI 的 ChatGPT-4o 模型(主要是花了钱,不用浪费不是😀)。大模型能做很多事,这里简单说说我们可以通过 ChatGPT 对 Emacs 做什么:

  • 让 ChatGPT 帮忙阅读代码,解释代码,这样以前看不懂的代码可以通过 ChatGPT 看明白
  • 让 ChatGPT 帮忙写代码,尤其是我们对 Emacs 的配置进行调整,需要写一些函数甚至插件
  • 让 ChatGPT 优化已经写好的代码,让代码更加规范和可读
  • 让 ChatGPT 充当 Emacs 百科,充当 Google 的角色,回答各种配置问题

所有的这一切,都需要有一个前提,就是你需要学会「正确的问问题」。我想这个技能,不是因为 ChatGPT 的出现才出现,这个问题亘古至今如此,只是对象,从以前问夫子,到网络时代问网友,再到 AI 时代问大模型,其原理不变其宗。

我们与 ChatGPT 的交流,主要通过提示工程。提示词是有方法论的,网上有很多教程,这里不做介绍。与 AI 交流,其实就跟练剑一样,练的多了,也就会了剑,无他,惟手熟耳!

关于这些案例,我在与 ChatGPT 交谈时,实际上并没有很好地遵循提示工程的“三段式”,比较随性,仅供参考。

3 中文按词移动和删除 🔗

3.1 背景 🔗

大家知道英文的单词之间有空格,而中文的词语之间没有空格,这就导致了中英文之间对于词的处理逻辑完全不同。如果按照英文的逻辑,我们按词来移动光标,就会把所有连在一起的中文句子作为一个“词”,这显然不符合我们中国人的使用习惯。

之前一直用 cireu 大佬的 jieba.el 包,很好用,但是不知道是因为升级了 node 还是因为升级了这个包,突然不能用了,一直报如下的错误,以我这个粗浅的水平捣鼓了半天也没找到解决方法。

map-keymap: Wrong type argument: processp, #[0 “���� �����������\”!& �" [“JIEBA-SERVER” jieba-server-start-args make-process :name :command :coding utf-8-emacs-unix :noquery t :connection-type …] 16]

于是就产生了找 ChatGPT 写一个自用版本的想法。

3.2 正确的描述我们的问题 🔗

3.2.1 第一次描述问题 🔗

令人欣喜的是 ChatGPT 给出的初始解决方案就是用 Python 实现的分词部分,这也正是我想要的。

3.2.2 测试以及需求累进的描述 🔗

我们需要对每一个模块进行测试,并且将测试的结果,精确的描述给 ChatGPT,让 ChatGPT 给出更新的代码,然后我们再次重试。

整个过程会遇到很多问题,例如性能的问题,刚开始的版本很卡:

当然,ChatGPT 并不总是正确的,至少我个人觉得他给的 2 个解决方案,并不是很好,我自己想了下给了他一个可能的解决方案来解决性能问题:

以及,我们将 Python 脚本服务化,通过 REST API 的方式来调用:

中间并不是一帆风顺,还会遇到各种报错和警告:

进一步地,我们要考虑在 Mac 上如何优雅的启动这个服务:

最麻烦的就是当你搞定了 Python 脚本和服务,搞定了其中 Backward 方向移动的时候,在 Forward 方向却一直无法正确的移动,此时,也许需要一点点自己的判断力、逻辑思维能力和创造力,我现在回想来,在整个过程中,我自己发挥了 2 个重要的作用:

  1. 提供了一个思路是把光标所在的句子文本送去分词,而不是 ChatGPT 提供的把整个 buffer 送过去分词
  2. 在经过大量的测试后发现,光标在移动到句子分界线尤其是句首的时候,总是出现问题,于是我扩展了送过去分词的文本范围,一句不行,我把两句一起送过去,这样既解决了性能的问题,也解决了边界的问题,当然这个方法并不优美,算是一个笨方法。

3.3 最终的效果 🔗

最终我们得到了如下的效果:

  1. 我们按下 M-fM-b 可以实现中文的按词向前向后移动,而且兼容英文。我们按下 M-DELM-d 实现了向后和向前按词删除。
  2. 我们利用了一个非常简单的 Python 程序来做 jieba 服务后端,其他部分完全通过 elisp 实现。
  3. 我们综合的考虑了性能和效果。

3.4 最终的代码和使用 🔗

3.4.1 Python 服务端 🔗

Python 服务端代码:

你可以将端口替换成你想用的端口,我这里随便挑了一个不太会被其他程序占用的端口。

from flask import Flask, request, jsonify
import jieba

# jieba.setLogLevel(jieba.logging.ERROR)

app = Flask(__name__)

@app.route('/segment', methods=['POST'])
def segment_text():
    data = request.json
    text = data.get('text', '')
    if not text:
        return jsonify([])  # 如果没有提供文本,返回一个空列表
    words = jieba.lcut(text)
    return jsonify(words)

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=27350)

此时你就可以通过 python segment.py 来启动这个后端服务了。


如果你想在 Mac 上以 launchctlgunicorn 来启动服务,ChatGPT 也帮我们生成了如下的 plist 文件:

请注意,请务必将下面的文件所有路径替换成你自己的路径!

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.remacs.segment</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/randolph/.venv/py310/bin/gunicorn</string>
        <string>--chdir</string>
        <string>/Users/randolph/bin/python_script</string>
        <string>-w</string>
        <string>4</string>
        <string>-b</string>
        <string>127.0.0.1:27350</string>
        <string>segment:app</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/Users/randolph/.venv/py310/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    </dict>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/randolph/Temp/com.remacs.segment.out</string>
    <key>StandardErrorPath</key>
    <string>/Users/randolph/Temp/com.remacs.segment.err</string>
</dict>
</plist>

然后我们可以通过如下的命令来启动这个服务:

launchctl load ~/Library/LaunchAgents/com.remacs.segment.plist

3.4.2 elisp 代码 🔗

(require 'request)

(defun call-jieba-segment (text)
  "调用 RESTful API 使用 jieba 分词 TEXT。"
  (let ((response (request
                    "http://127.0.0.1:27350/segment"
                    :type "POST"
                    :data (json-encode `(("text" . ,text)))
                    :headers '(("Content-Type" . "application/json"))
                    :parser 'json-read
                    :sync t)))
    ;; (message "请求状态: %s" (request-response-status-code response))
    ;; (message "请求响应: %S" (request-response-data response))
    (if (not (eq (request-response-status-code response) 200))
        (error "调用 jieba 分词 API 失败: %s"
               (request-response-status-code response))
      (request-response-data response))))

(defun get-current-sentence ()
  "获取当前光标所在的句子。"
  (save-excursion
    (let (beg end)
      (setq beg (progn (backward-sentence) (point)))
      (setq end (progn (forward-sentence) (forward-sentence) (point)))
      (buffer-substring-no-properties beg end))))

(defun get-segmented-words ()
  "获取当前句子的分词结果。"
  (let* ((sentence (get-current-sentence))
         (words (call-jieba-segment sentence)))
    (append words nil)))

(defun get-sentence-boundaries ()
  "获取当前句子的起始和结束位置。"
  (save-excursion
    (let (beg end)
      (setq beg (progn (backward-sentence) (point)))
      (setq end (progn (forward-sentence) (forward-sentence) (point)))
      (cons beg end))))

(defun backward-word-by-jieba ()
  "基于 jieba 分词结果向后移动一个单词。"
  (interactive)
  (let* ((words (get-segmented-words))
         (pos (point))
         (boundaries (get-sentence-boundaries))
         (sentence-start (car boundaries))
         (word-starts (let ((start sentence-start)
                            starts)
                        (dolist (word words)
                          (setq starts (append starts (list start)))
                          (setq start (+ start (length word))))
                        starts)))
    (catch 'break
      (dolist (start (reverse word-starts))
        (when (< start pos)
          (goto-char start)
          (throw 'break nil))))))

(defun backward-delete-word-by-jieba ()
  "基于 jieba 分词结果向后删除一个单词。"
  (interactive)
  (let* ((words (get-segmented-words))
         (pos (point))
         (boundaries (get-sentence-boundaries))
         (sentence-start (car boundaries))
         (word-starts (let ((start sentence-start)
                            starts)
                        (dolist (word words)
                          (setq starts (append starts (list start)))
                          (setq start (+ start (length word))))
                        starts)))
    (catch 'break
      (dolist (start (reverse word-starts))
        (when (< start pos)
          (delete-region start pos)
          (throw 'break nil))))))

(defun forward-word-by-jieba ()
  "基于 jieba 分词结果向前移动一个单词。"
  (interactive)
  (let* ((words (get-segmented-words))
         (pos (point))
         (boundaries (get-sentence-boundaries))
         (sentence-start (car boundaries))
         (word-starts (let ((start sentence-start)
                            starts)
                        (dolist (word words)
                          (setq starts (append starts (list start)))
                          (setq start (+ start (length word))))
                        starts)))
    (catch 'break
      (dolist (start word-starts)
        (when (> start pos)
          (goto-char start)
          (throw 'break nil))))))

(defun forward-delete-word-by-jieba ()
  "基于 jieba 分词结果向前删除一个单词。"
  (interactive)
  (let* ((words (get-segmented-words))
         (pos (point))
         (boundaries (get-sentence-boundaries))
         (sentence-start (car boundaries))
         (sentence-end (cdr boundaries))
         (word-starts (let ((start sentence-start)
                            starts)
                        (dolist (word words)
                          (setq starts (append starts (list start)))
                          (setq start (+ start (length word))))
                        starts)))
    (catch 'break
      (dolist (start word-starts)
        (when (> start pos)
          (delete-region pos start)
          (throw 'break nil)))
      ;; 如果没有找到大于 pos 的分词起始位置,则删除到句子的结束位置
      (when (< pos sentence-end)
        (delete-region pos sentence-end)))))

;; 将函数绑定到快捷键
(global-set-key (kbd "M-b") 'backward-word-by-jieba)
(global-set-key (kbd "M-DEL") 'backward-delete-word-by-jieba)
(global-set-key (kbd "M-f") 'forward-word-by-jieba)
(global-set-key (kbd "M-d") 'forward-delete-word-by-jieba)

3.5 完整的聊天记录 🔗

这个案例,是我最近这段时间花时间最多,最复杂的案例,当然主要原因也是我自己水平有限。因此这个聊天记录里,有很多非常低级的问题,最终生成的代码也应该有很大的提升空间,仅供参考。

与 ChatGPT 的完整聊天记录

4 结语 🔗

今天这个案例,是一个相对复杂的案例,它涉及到了 Emacs 和外部服务之间的交互,整个聊天过程也比较复杂,除了 ChatGPT 之外,还需要动很多脑筋想办法解决问题。最终实现自己需求的那一刻,那份满足感难以言表。

后续,将会继续分享几个案例。

  • Org mode 代码块的出错信息
  • org-super-links 链接与反链改造

我也会持续的使用 ChatGPT 和 Emacs,持续分享我的心得,感谢您的阅读。