Skip to main content

4 posts tagged with "TiddlyRAG"

View All Tags

Wei Ji

前情提要

  • MWS (MultiWikiServer) 是正在開發中的 TiddlyWiki 後端方案。
  • MWS 的主要開發者是 TiddlyWiki 社群的核心人物,同時 MWS 相關的開發資訊是掛在 TiddlyWiki 的組織帳號以及網域下,因此地位上是「TiddlyWiki 正宗承認的後端專案」。
  • 經過田野調查後有以下發現:
    • AuthZ 方案選擇使用 ACL,大部分的 Issue 都在繞著 ACL 相關的問題討論。
      • 然而 MWS 的資料模型是建立在 Recipe-Bag 多對多關係下,ReBAC 才是合適的 ACM。換句話說,這個專案正在經歷錯誤的技術決策造成的泥沼。
    • 關於 Admin UI 的討論則是第二多的討論。
      • 然而此時 MWS 的核心運作依然不穩定,並沒有提供方便 DevOps 快速搭建服務的選項(如:預建置 OCI 映像檔)。
      • 其中甚至涉及透過 RPC 實現前後端混合渲染之類的話題。(基本上重新發明 Nest.js)
    • 程式碼風格延續 TiddlyWiki 時期的邊界模糊。
      • 可靠性堪憂。
      • 安全性堪憂。
      • 「社群自 high 專案」,無法吸引使用主流技術棧的開發者。
      • 大量的「重複輪子」程式碼。
    • 主要開發者認為「Docker 對使用者不友善」。
  • TiddlyWeb 是起於 2008 年,止於 2021 年 Python 實做的 TiddlyWiki 後端。Recipe-Bag 的模型源自於此。
    • TiddlyWeb 的程式碼大小大約為 600 KiB, MWS 則是 MiB。
    • TiddlyWeb 的 API 較為乾淨,沒有複雜的 RPC 或是混亂的 ACL 設計。
    • TiddlyWeb 內有單元測試。
    • TiddlyWeb 的程式碼多停留在 Python 2.7 時期,部份實做 2.7/3.3 兼容程式。

簡短結論

先講結論,我把核心邏輯的實做跟測試案例都搬到 Python 3.12 去了,程式碼在此:

https://github.com/FlySkyPie/tiddlyrag-poc/tree/poc/type-e

不過 HTTP API 的實作就沒繼續了,後面會說明原因。

原訂計畫

大方向上,在遵守 TiddlyWiki 社群共識的模型與 API,製作一個兼容的 API 伺服器,不只作為 TiddlyRAG 的微服務組件,更能回饋 TiddlyWiki 社群。拆成兩個階段:

  1. PyNest 階段
    1. PyNest 是仿造 NestJS 風格的 Python/FastAPI 後端伺服器框架
    2. 將 TiddlyWeb 的實作逐步遷移到現代框架/環境中。
  2. NestJS 階段
    1. 將 PyNest 專案轉譯成 NestJS,回歸 TiddlyWiki 的 Node.js 系統。

第一階段的細節,使用乾淨架構:

  • Domain
    • 不仰賴函式庫
    • 使用缺血模型模仿 TiddlyWeb 的資料結構
    • 使用 typing.Protocol 抽象 TiddlyWeb 的模型方法
  • Infrastructure
    • TiddlyWeb 的實作與測試會置於這一層
  • Application
    • PyNest 的實作,主要是 HTTP 伺服器一類的邏輯。

如此設計的原因是 TiddlyWeb 的實做缺乏現代開發的邊界感,實作往往有高度的耦合性,很難直接切割,重構策略:

Legacy_Model = 缺血模型 + 界面 + Infrastructure 實作
Legacy_Test(Infrastructure 實作)

盡可能保持原始的界面,來重複使用單元測試,確保複製原始的業務邏輯。

整個過程會是 Strangler Fig Pattern:

  • 大部分實做會置於 Infrastructure 層
  • HTTP 相關的落伍實作會由 Application 層取代
  • 足夠乾淨的實作會放到 Domain 層

過程中可以慢慢把舊的領域模型與業務邏輯轉譯成現代軟體的建模方式(Service, Repository...)。

重構(遷移)過程與程式碼回顧

這邊解釋一下為什麼要把原本的充血模型分解掉:

Legacy_Model = 缺血模型 + 界面 + Infrastructure 實作
Legacy_Test(Infrastructure 實作)

因為在現代後端,Query 類的問題幾乎都交由資料庫進行優化,而從資料庫檢索出來的東西也就只有資料,沒有方法,所以當模型仰賴資料庫進行持久化時,最好不要假設有太多方法的存在。

除此之外,遷移過程我還進行了一些修改,確保程式碼符合現代風格,或是為過度到現代風格做準備。

OOP

TiddlyWeb 的大部分實做都是函式,並且透過 environ 來傳遞大部分資訊,只有少部份的邏輯有封裝成物件。這似乎是 CGI (Common Gateway Interface)/WSGI 延續化來的風格。

info

CGI 時期的 web server 實作其實是一個執行檔,仰賴環境變數與 STDIO 與 parent process 溝通。parent process 是真正對外開埠的程式,在收到 request 之後把 URI 轉換成環境變數去呼叫 CGI 程式,再把拿到的字串作為 response 返回。

透過將原始實做封裝到物件內,我就可以將方法抽離成 Protocol,並且透過建構子解偶對其他函式的仰賴,之後就能清楚的的知道整個仰賴鏈,舉例來說這是重構後測試程式的片段:

    specialbag_mock = MagicMock(spec=SpecialBagInterface)
helper = TextSerialization(specialbag_mock)
serializer = SerializationFacade(helper)
store_helper = MemTextStoreHelper(serializer)
store = StoreFacade(store_helper)

select_filter = SelectFilter(store)
sort_filter = SortFilter(store)
filter_facade = FilterFacade(select_filter, sort_filter)
resolver = OverlayResolver(filter_facade)

Facade 模式

TiddlyWeb 本身有使用 OOP 的實作都是 Facade 模式,並且有這幾個:

  • Serializer
    • html
    • json
    • text
  • Store
    • text

物件對外是用像這樣的語法被使用:

serializer = Serializer('json')

內部則是使用像這樣的語法動態載入:

imported_module = __import__('tiddlyweb.serializations.%s'
% self.engine, {}, {}, ['Serialization'])

一股 Javascript 弱型別特性語言的 require() 味撲鼻而來...

如同你在前面測試程式碼看到的,這的部份我也是用 ID (Dependency Injection) 處理掉了。

另外一個部份 TiddlyWeb 也有使用類似於 Facade 模式的東西,雖然沒有建立 class:

  • filters
    • limit
    • select
    • sort

先建立規則表:

FILTER_PARSERS = {
'select': select_parse,
'sort': sort_parse,
'limit': limit_parse,
}

然後根據輸入的 filter 規則視情況選擇具體的解析實做

    filters = []
leftovers = []
for string in strings:
query = parse_qs(string)
try:
key, value = list(query.items())[0]

# We need to adapt to different types from the
# query_string. It changes per Python version,
# and per store (because of Python version).
# Sometimes we will already be unicode here,
# sometimes not.
try:
argument = unicode(value[0], 'UTF-8')
except TypeError:
argument = value[0]

func = FILTER_PARSERS[key](argument)
filters.append((func, (key, argument), environ))
except(KeyError, IndexError, ValueError):
leftovers.append(string)

leftovers = '&'.join(leftovers)
return filters, leftovers

Overlay

解明 Recipe-Bag 的模型邏輯算是調查的重點之一,在 TiddlyWeb 中是被放在名為 control.py 的檔案中,因為它需要從 GET /recipes/{recipe_name:segment}/tiddlers[.{format}] 這樣的路徑中挑選真正的 tiddler。

但是這會和現代 MCV 架構中的 Controller 撞名,於是我把相關邏輯風裝在名為 OverlayResolver 的物件內。

測試案例

在遷移完核心邏輯之後,就是遷移相關的測試案例,整個過程就是不斷的重複這個步驟:

commit 4630d5c8e58e0531342b64bca8cf3a849d7b854c
Author: Wei Ji (FlySkyPie) <c445dj544@gmail.com>
Date: Sat Jun 6 20:24:31 2026 +0800

feat: adjust test

commit 86e77b44f8603b4fe47ec9e2195fb14987b79c9b
Author: Kilo Code v4.153.0 <deepseek+deepseek-v4-flash@openrouter.ai>
Date: Sat Jun 6 20:20:09 2026 +0800

test: refactoring test to match current project

commit db58f75f56c4aaa9c2f63ef2eebdb91c32d0d909
Author: Wei Ji (FlySkyPie) <c445dj544@gmail.com>
Date: Sat Jun 6 20:17:14 2026 +0800

chore: move test file from legacy
  1. 把原本的測試檔案移到 Infrastructure 中,這個時候跑 pytest 會直接壞掉,因為有嚴重的語法錯誤。
  2. 讓 LLM 做重構,這時 pytest 就會恢復功能,可以看到 PASSED 跟 FAILED
  3. Review 看有沒有測試案例被 LLM "偷懶"刪掉的,如果有就補回去,或是適度的刪掉過期的測試案例,例如前面講的動態 import 就有測試案例在測這個,不過既然我不使用字串動態 import 了,有些"預期無效字串錯誤"的案例就可以刪掉了。
  4. 有時候測試案例是好的,但是遷移過程中遺漏的邏輯造成失敗,就把實作補回去。

一開始的測試案例我都白嫖 Gemini Flash,後面等到模式建立起來之後就用開放權重的模型跑了,提示詞不複雜:

Your task:
- Read `tiddlymicroweb/infrastructure/store/tests/test_tiddler_slash.py`
- Refactoring `tiddlymicroweb/infrastructure/entities/test_user.py` by following previous refactoring pattern.

一次重構的最終上下文大約為 40k 左右。

info

最終上下文是指最後一個步驟的 token 數,整個 ReAct 過程總用量要再乘以次數除以二。

另外補充,TiddlyWeb 的程式碼我是透過 Git Subtree 放進專案的,目的是為了保留歷史上下文,同時必要時 ReAct 可以回去檢視 Legacy code。

MemTextStoreHelper

TiddlyWeb 原本只實做了 text,會直接把資料寫入檔案系統的 store 實做,並且所有測試都是整合測試,實例之間又有複雜的仰賴,對 store 進行 Mock 其實不切實際。

於是我使用 pyfilesystem2,保留界面與大部分內部實作邏輯,把寫入對象從系統的檔案系統改成記憶體虛擬出來的檔案系統。如此一來便可在最少修改的前提下讓原本的整合測試能夠更「單元」一點。

結論

在 TiddlyWeb 中,關鍵的業務邏輯被分成了三個區塊:

  • Filter
  • Overlay
  • Store

然而這帶來了一個問題,這在現代後端都是需要跟資料庫溝通的行為,通常被抽象化成 Repository。先不談 Overlay 和 Store 在現代架構下可以被合併成一體,問題在於 Filter。

Filter 存在的目的是提供類似 TiddlyWiki 的語法,但是目前實作都是在 Python 內進行的,如果想要保持兼容性,標準的解法是使用 DSL (Domain-specific language) 或 AST (Abstract syntax tree) 作為 Filter 語法和 SQL 的中介層,但是建構並維護這樣一個中介層的成本非常的高。

另外,因為測試案例都是整合測試,因此當軟體結構遷移到現代的抽象方式時(Service, Repository...),這些測試都變得毫無用處。

簡言之,雖然我成功把核心的業務邏輯以及相關的測試案例重構到現代環境,因為建模的方式依然和現代模型之間存在巨大的鴻溝,實在很難在完整兼容的前提遷移到現代系統。

抽出 OpenAPI 模型,並在理解舊有實作和測試案例的前提下直接重新實作一個部份兼容的 API Server 可能是更好的選擇。

Wei Ji

前情提要

TiddlyRAG Dev Log - MWS API | 工程屍 FlyPie 的異想世界

在前一篇文章中,我提到了 MWS (MultiWikiServer) 時做了一個名為 Recipe-Bag 多對多模型,用來展現類似於 OverlayFS 的行為。

但是我依然不能百分之百確定這個模型的設計哲學以及正確的行為是什麼,舉例來說:

  • 對 Recipe 名下的某個 Tiddler 寫入時,是永遠寫到 Bag 的最上層嗎?這是不是代表每個 Recipe 都至少包含一個 Bag 作為 Overlay?
  • Recipe-Bag 模型是從哪裡來的?是如我直覺的啟發自 OverlayFS 還是其實長得很像但是細節不太一樣?

翻譯 ETL

於是我計畫建構一個 ETL (Extract, Transform, and Load) 來處理這個問題:透過 GitHub API 拉出 Issue 和 Comment (討論紀錄)、進行翻譯、最後輸出成方便我閱讀的 Markdown。

除了表面的目的以外還有其他考量:最近我在 104 看職缺的時候,發現一些關於 ETL 的職位會提到 Perfect 這類工具,於是我想這是一個嘗試新東西的機會。

雖然我也可以嘗試透過最近完成的 EC-BT (Entity Component Behavior Tree) 進行資料探勘,但是我依然需要對標準的 ETL Pipeline 有基本的認識才能避免重複發明輪子。

一開始我是試著搭建 Prefect 的環境,不過過程中我看到了這個:

這讓我的開源雷達亮紅燈。加上它的文件不是很清楚,我沒辦法一下子搞清楚誰是 Client?誰是 Server?誰是 Workder?

另外一點,這是 Luigi 的官方範例1

class AggregateArtists(luigi.Task):
date_interval = luigi.DateIntervalParameter()

def output(self):
return luigi.LocalTarget("data/artist_streams_%s.tsv" % self.date_interval)

def requires(self):
return [Streams(date) for date in self.date_interval]

def run(self):
artist_count = defaultdict(int)

for input in self.input():
with input.open('r') as in_file:
for line in in_file:
timestamp, artist, track = line.strip().split()
artist_count[artist] += 1

with self.output().open('w') as out_file:
for artist, count in artist_count.iteritems():
print(artist, count, file=out_file)

它顯式的表明了一個 Task 的輸入與輸出,比較符合我對 ETL 的想像,畢竟是 ETL (Extract, Transform, and Load) 對吧?

反之 Prefect 的官方範例則是長這樣:

from prefect import flow, task
import httpx


@task(log_prints=True)
def get_stars(repo: str):
url = f"https://api.github.com/repos/{repo}"
count = httpx.get(url).json()["stargazers_count"]
print(f"{repo} has {count} stars!")


@flow(name="GitHub Stars")
def github_stars(repos: list[str]):
for repo in repos:
get_stars(repo)


# run the flow!
if __name__ == "__main__":
github_stars(["PrefectHQ/prefect"])

好,你定義了一個像是 Pipeline 的東西,但是你的 Load 勒?

另外,最近學習 NestJS 和 FastAPI 的經驗讓我對於 Prefect 的語法有一點防禦心理。FastAPI 高度仰賴 Dep 注入函式的方式建構,副作用就是專案的組織充滿可能性,造成程式碼很雜亂,反而是 NestJS 基於物件的結構直接隱式的決定了程式碼的位置以及如何在專案中組織。

看起來好像有點用... 但老實說,我覺得用一個簡單的 Makefile 就能搞定所有這些東西了。用這種工具的好處是什麼啊?

路易吉的確是受到 GNU make 的啟發(文章裡不同地方有提到),而且文章裡的微型範例並沒有展現它所有的功能。它跟 Spark 和 Hadoop 這樣的框架配合得很好,它支援開箱即用的不同資料儲存目標,你有錯誤報告、依賴關係圖視覺化等等。 2

這個算是我嘗試 Luigi 的原因之一,CMake 或 Bazel 這類本地建置工具長什麼樣子我心裡有底,所以我需要的是一個光譜在 CI/CD 或本地自動化工具以外的 ETL/Data Orchestration 工具來了解領域模型。

貼文有 11 年老,但是 Luigi 看起來還有在維護,UI 以現代眼光來看也不會太差。初試 Prefect 無果之後我便嘗試 Luigi,

然後沒遇到什麼問題就接著實作,最後把我的需求解決掉了。

Luigi

先講結論,程式碼在此:

https://github.com/FlySkyPie/github-issue-simple-etl

它在 Luigi 的框架下運行了 ETL:

  • 從 GitHub 下載 Issue
  • 利用 LLM (OpenAI-Compatible API) 翻譯
  • 將翻譯結果做成適合閱讀的 Markdown
  • 適度的暫存與事務 (Transaction) 操作

我用過 Jenkins 之類的 CI/CD,也用過 CMake 之類的本地自動化,這個所謂的 ETL 工具憑什麼自立一個名為 Data Orchestration 的領域?

我沒有完整的答案,但從快速瀏覽 的氣流教學 來看,最明顯的是它是一個自上而下的 DAG 規格,並手動建立節點之間的連接。

相比之下,Luigi 元素之間的連接是隱式的,而且是自下而上的。 3

注意,這裡的上下不是組件的基礎與抽象的上下,而是資料上游下游的上下。CI/CD 這種需要某種東西觸發 (Git Branch 更新)的 Pipeline 就是由上而下,而由下而上更長出現在本地自動化,比如 Makefile:

main: main.o sub.o
gcc main.o sub.o -o main
main.o: main.cpp
gcc main.cpp -c
sub.o: sub.cpp
gcc sub.cpp -c
clean:
rm -rf main.o sub.o

我想要 main 的時候,會觸發它的仰賴 main.osub.o 於是自動化工具一路找到最上游還沒進行的任務把事情做掉。這一事實會讓工具變成冪等性的,而這也是 Luigi 具備但是不屬於 CI/CD 核心的特性(某些 CI/CD 會透過 Artifact 優化 Pipeline,但是不是特別重視這個特性,它們比較在意產出 latest)。

令一方面 ETL 比起本地自動化更注重分散負載,即 CI/CD 常用的 Server-Worker 架構,ETL 則是缺乏「佈署」或「觸發」的概念,因此多了一個直接操作 Pipeline 的 Client。

不過我認為 ETL 最重要的特性之一是「動態 Pipeline」,不論是 CI/CD 還是本地自動化,參數通常是一開透過設定檔、平台變數或是環境變數決定的,然後 Pipeline 的「形狀」通常是固定的,然而以我的實作為例:

實際上我只定義了五種 Task:

  • DownloadIssues
  • GenerateDocument
  • GenerateSingleDocument
  • StoreTranslation
  • TranslateSingleIssue

運作後的 Pipeline 中的很多實際任務節點是根據 GitHub repo 下的 Issue 數量與號碼指定的,對於一個翻譯任務節點而言,它的設定如下:

class TranslateSingleIssue(Task):
target_repo: Parameter = Parameter(
description="The issues of GitHub Repo going to translate",
)

api_base: Parameter = Parameter(
description="API base url of OpenAI-Compatible API",
)

model_name: Parameter = Parameter(
description="model name of LLM",
)

language_name: Parameter = Parameter(
description="Target language of translation",
)

issue_number: IntParameter = IntParameter()

api_basemodel_namelanguage_name 是可以在 .cfg 檔案設定的,但是 target_repoissue_number 是它的下游指定的:

class GenerateSingleDocument(Task):
def requires(self):
return [
TranslateSingleIssue(
target_repo=self.target_repo, # type: ignore
issue_number=self.issue_number,
)
]

如此一來資料操作者可以輕易的設定全域變數或是指定某個下游任務的參數,它會自己「把訂單往上游傳」然後動態的根據不同的仰賴結構建構不同的 Pipeline。

MWS 的調查結果

在我閱讀了 111 個 Issue 後,我驚訝的發現討論大部份圍繞在 ACL (Access Control List) 上,Admin UI 的討論則是次之。

專案的 ACM (Access Control Model) 選擇使用 ACL 是缺陷這個我在前一篇文章就講過了。我沒講的是 Recipe-Bag 這種多對多模型如果要處理 AuthZ (Authorization) 最簡單可靠的方式就是引入 ReBAC (Relationship-based access control)。

好,他們正在錯誤的技術決策上打轉,然後又過度關注 UI,與此同時我還是沒有一個可靠的微服務組件可以使用,怎麼回事?說到底 Recipe-Bag 這個模型到底怎麼來的?

The bags and recipes model is a reference architecture for how tiddlers can be shared between multiple wikis. It was first introduced by TiddlyWeb in 2008.4

於是我回去翻了一下 tiddlyweb 的 Git 紀錄:

看起來是非常早期的 commit 的引入的設計,甚至還在 SVN 時期,我大概很難找到相關討論了,總之這麼模型甚至被 TiddlyWiki 的官方文件收錄了。


另外一個發現是它們在討論某種前後端同步或是後端渲染的機制,這對我而言就是「重新發明 Next.js」!這也解釋了為什麼 API 裡面會有這麼多奇怪的 RPC (Remote procedure call)。

最後讓我得出結論的是這個,MWS 的主要貢獻者說道:

Docker itself doesn't strike me as being very friendly to new users. I'm not sure how you mean we should support Docker Compose.5

如果一個後端專案的主力開發者無法正視 Docker 在現代生態系的意義,那這個專案可以丟進垃圾桶了。

下一步

tiddlyweb 用 repomix 打包只有 600 KiB 左右,反觀 MWS 則是 2 MiB,tiddlyweb 抽出來的 OpenAPI 也比較簡潔,沒有 ACL 或是「類 Nest.js」造成的臃腫。我決定把 tiddlyweb 作為 rewrite 對象而不是 MWS。

後記

最近我意外得知,研究方法中除了定量分析以外,還有一種注重少量樣本與群體,更重視「故事」與「脈絡」的質性研究。這才意識到自己其實是對軟體專案進行質性研究呢。

Footnotes

  1. Example – Top Artists — Luigi 3.8.1 documentation. https://luigi.readthedocs.io/en/stable/example_top_artists.html

  2. 用 Luigi 在 Python 裡建資料管道 : r/Python. https://www.reddit.com/r/Python/comments/3rjkry/comment/cwppm86/?tl=zh-hant&utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

  3. 用 Luigi 在 Python 裡建資料管道 : r/Python. https://www.reddit.com/r/Python/comments/3rjkry/comment/cwppm86/?tl=zh-hant&utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

  4. Bags and Recipes: TiddlyWiki v5.4.0 — a non-linear personal web notebook. https://tiddlywiki.com/static/Bags%2520and%2520Recipes.html

  5. Feature Request: Support installation and deployment using Docker Compose · Issue #122 · TiddlyWiki/MultiWikiServer. https://github.com/TiddlyWiki/MultiWikiServer/issues/122#issuecomment-3702946673

Wei Ji

前情提要

POC Type-A 探索了向量資料庫、嵌入模型並對外暴露 MCP (Model Context Protocol):

POC Type-B 探索了 AsyncFuncAI/deepwiki-open 專案處理「如何把 Git Repo 轉換成文件?」這個問題的方式,並做出了一些改良,其中一個重點改良是將 Git Repo 微服務化,TiddlyRAG 不直接與檔案系統中的 Git Repo 互動,而是透過 Gitea 提供的 API:

如此一來 Git Repo 相關的業務邏輯就變成了 TiddlyRAG 不關注的通用域問題。舉例來說,TiddlyRAG 不再需要考慮 git clone 失敗與否產生的各種 edge case,只需要呼叫 Gitea 中 migrate 的 API 即可。

透過 HTTP API 操作,更容易實現權限控制與訪問審計等需求,這在基於 LLM 建構的智能體 (Agent) 系統帶來的不可空性中顯得更為重要。

POC Type-C 探索了 ECS (Entity-Component-System) 與行為樹 (Behavior Tree) 建構的智能體框架:

並實做了簡單的深度優先遍歷與廣度優先遍歷作為概念驗證。

TiddlyWiki 微服務

在 POC Type-A 中,TiddlyWiki 是以相對隨便定義的方式儲存在 TiddlyRAG 內的資料庫中,但是就像 POC Type-B 處理的問題一樣,「TiddlyWiki」的儲存並不是 TiddlyRAG 關注的問題,因此在 TiddlyRAG 的微服務架構中,應該要有一個專門用儲存 TiddlyWiki 的實例。

以下是我經過調查,跟這個主題有關的專案:

TiddlyServer 的作者同時也是 MultiWikiServer 的主要貢獻者,並且在 README 明確的表明該專案已經中止,後續的開發能量會轉移給 MultiWikiServer

tiddlyhost-com 雖然有「多租戶」的概念,但是它的主要運作方式是以 static asset 的方式 serve 多個 TiddlyWiki 檔案,因此就算它有 API,原子操作也是 TddilyWiki (整個網頁)而不是 Tiddler (條目)。

tiddlyweb 是完全以 Python 實做的伺服器,然而大部分實作依然停留在 Python 2.7 時期,甚至連 Python 3.3 的遷移也尚未完成就已經中止開發。

並且它是基於 TiddlyWiki 2.0 設計的,現代的 TiddlyWiki (TW5) 要使用這個專案必須做一些調整1

MultiWikiServer 似乎是 TiddlyWiki 生態系中以官方的角色建構的「TiddlyWiki 微服務後端方案」,然而它依然存在一些問題。

MWS 的問題

接下來解釋一下 MWS (MultiWikiServer) 有哪些問題,如果依照 README 上的指南試著建立環境:

  • npm init @tiddlywiki/mws@latest my-folder
  • cd my-folder
  • npx mws init-store
  • npx mws listen --listener

會遇到很多光怪陸離的事情。

其他套件管理工具不友善

因為在 @tiddlywiki/create-mws 寫死 npm,所以就用:

pnpm create @tiddlywiki/mws@latest my-folder

得到的 node_modules 會報錯。

hard code 仰賴

試圖安裝並執行 mws 指令時也會報錯,因為:

{
"dependencies": {
"prisma-client": "file:prisma/client",
}
}

package.json 的仰賴是寫死的。

Docker 安裝

如果依照 README 的 docker 安裝步驟:

# Create your data directory
mkdir my-mws-data
cd my-mws-data

# Download the required files
curl -O https://raw.githubusercontent.com/TiddlyWiki/MultiWikiServer/main/docker-compose.directory.yml
curl -O https://raw.githubusercontent.com/TiddlyWiki/MultiWikiServer/main/Dockerfile

# Create store directory
mkdir -p store

# Start MWS
docker-compose -f docker-compose.directory.yml up -d

# Initialize the database (required on first run)
docker-compose -f docker-compose.directory.yml exec mws npx mws init-store

# Access at http://localhost:8080
# Default credentials: admin / 1234

則會得到一個非常簡易的 React.js 前端頁面。

設計哲學與程式風格

MWS 的設計依然部份延續 TiddlyWiki 的路徑,也就是軟體邊界模糊(前後端不分),以及為了保持自舉的特性,很多功能不使用函式庫而是自己重複造輪子。

舉例來說,透過 Docker 運行起來後,我的瀏覽器插件顯示它的前端使用 React,於是我便調查一下的程式碼,確實有前後端分離的跡象,但是依然出現疑似 JSX 輪子的東西:

packages/jsx-runtime/jsx-render.ts
import { MaybeArray } from "./jsx-utils";

export const JSXElementSymbol: unique symbol = Symbol("__is_jsx_element__");

const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
const MATH_NAMESPACE = "http://www.w3.org/1998/Math/MathML";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";

const OldPropsSymbol: unique symbol = Symbol("__old_props__");
const KeyChildren: unique symbol = Symbol("__key_children__");
const OwnKeySymbol: unique symbol = Symbol("__own_key__");
const DOMElementSymbol: unique symbol = Symbol("__dom_element__");

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
const callbacks = observerCallbacks.get(mutation.target as Element);
if (callbacks) for (const cb of callbacks) cb();
}
});

const observerCallbacks = new WeakMap<Element, (() => void)[]>();

export function render(root: Element | DocumentFragment, child: MaybeArray<JSX.Node>) {
updateChildren(root, [child].flat(Infinity) as JSX.Node[]);
}

另一方面則是不使用 Express.js 之類主流的後端路由框架,而是自己創造另外一種模式:

packages/mws/src/managers/wiki-external.ts
  handleLoadBagTiddler = zodRoute({
method: ["GET", "HEAD"],
path: BAG_PREFIX + "/:bag_name/tiddlers/:title",
bodyFormat: "ignore",
zodPathParams: z => ({
bag_name: z.prismaField("Bags", "bag_name", "string"),
title: z.prismaField("Tiddlers", "title", "string"),
}),
inner: async (state) => {
const { bag_name, title } = state.pathParams;
const bag = await state.assertBagAccess(bag_name, false);
throw await state.$transaction(async (prisma) => {
const server = new WikiStateStore(state, prisma);
return await server.serveBagTiddler(bag.bag_id, bag_name, title);
});
}
});

或是手刻 HTTP body 解析:

packages/mws/src/managers/wiki-utils.ts
export async function recieveTiddlerMultipartUpload(state: ZodState<"POST", "stream", any, any, zod.ZodTypeAny>) {

// Process the incoming data
const inboxName = new Date().toISOString().replace(/:/g, "-");
const inboxPath = resolve(state.config.storePath, "inbox", inboxName);
mkdirSync(inboxPath, { recursive: true });

const parts: MultipartPart[] = [];

interface UploadPart2 {
inboxFilename?: string;
value?: string;
hasher?: Hash;
length: number;
fileStream?: Writable;
hash?: string;
}

const incomingParts = new WeakMap<MultipartPart, UploadPart2>();
const inboxFiles = new WeakMap<MultipartPart, string>();
const valueParts = new WeakMap<MultipartPart, string>();

await state.readMultipartData({
cbPartStart: async function (part) {
const part2: UploadPart2 = {
hasher: createHash("sha-256"),
length: 0,
};

if (part.filename) {
const inboxFilename = (parts.length).toString();
const inboxFilename2 = resolve(inboxPath, inboxFilename);
part2.fileStream = createWriteStream(inboxFilename2);
inboxFiles.set(part, inboxFilename2);
} else {
part2.value = "";
}

},
cbPartChunk: async function (part, chunk) {
const part2 = incomingParts.get(part)!;
if (part2.fileStream) {
await new Promise<void>((res) => {
part2.fileStream!.write(chunk) ? res() : part2.fileStream!.once("drain", () => res());
});
} else {
const encoding = part.headers.get("content-type")?.charset || "utf8";
if (!Buffer.isEncoding(encoding)) {
throw new SendError("MULTIPART_INVALID_PART_ENCODING", 400, {
partIndex: parts.length,
partEncoding: encoding,
});
}
part2.value! += chunk.toString(encoding as BufferEncoding);
}
part2.length += chunk.length;
part2.hasher!.update(chunk);
},
cbPartEnd: async function (part) {
const part2 = incomingParts.get(part)!;

if (part2.fileStream) part2.fileStream.end();
else valueParts.set(part, part2.value ?? "");

part2.hash = part2.hasher!.digest("base64url");
part2.fileStream = undefined;
part2.hasher = undefined;
incomingParts.delete(part);
parts.push(part);
},
});

const partFile = parts.find(part => part.name === "file-to-upload" && !!part.filename);

if (!partFile) throw state.sendSimple(400, "Missing file to upload");

const missingfilename = "File uploaded " + new Date().toISOString();

const type = partFile.headers.get("content-type")?.mediaType;
const tiddlerFields: TiddlerFields = { title: partFile.filename ?? missingfilename, type, };

for (const part of parts) {
const tiddlerFieldPrefix = "tiddler-field-";
if (part.name?.startsWith(tiddlerFieldPrefix)) {
const name = part.name.slice(tiddlerFieldPrefix.length);
const value = valueParts.get(part)?.trim() ?? "";
(tiddlerFields as any)[name] = value;
}
}

const contentTypeInfo = state.config.getContentType(type);

const file = await readFile(inboxFiles.get(partFile)!);

tiddlerFields.text = file.toString(contentTypeInfo.encoding as BufferEncoding);

rmSync(inboxPath, { recursive: true, force: true });

return tiddlerFields;
}

結論與策略

MWS 依然處於非常早期的開發狀態,並沒有穩定到足夠被當作微服務使用,另外它已經實作一些 ACL (Access Control List) 相關的邏輯,如果在缺乏配套的情況下在 POC 中當作微服務使用會構成阻礙。例如:缺乏聲明式配置,必須手動初始化使用者,或是想要進行簡單的 CRUD 卻被 ACL 的 bug 阻擋。

然而就算撇開「開發早期」這個條件不談,差勁的程式碼職責分離除了會造成專案的成長受限以外(實作偏離範式,其他開發者看不懂、無法理解就無法貢獻),這種程式碼很容易產生漏洞,產生漏洞就不能有可靠的安全性保證,造成其身份驗證的功能虛有其表、形同虛設,不只無法提供安全性,反而拖累開發能量。經過調查之後我還發現它的 ACM (Access Control Model) 有根本性的缺陷,這個等等再補充。

因此我打算採取的策略是,根據原始實作抽出 HTTP API 界面,並使用它的資料庫設計當作參考,直接用 NestJS 重新實做,建立一個「MWS API 兼容伺服器」。

一來是出於對官方原始設計的尊重,並且透過兼容的方式避免跟 TiddyWiki 原始的生態系走太遠。二來是當我的實作走得比較前面時,發現的問題或解決方案可以反過來向 TiddlyWiki 的官方回報或貢獻。

目前進展

經過三天的研究,已經成功抽出 API 定義並且用 OpenAPI YAML 紀錄:

LLM (Large Language Model) 非常擅長幹這種事情,不過隨著進展到特定 API 的細節時往往還是會發現錯誤(幻覺),所以需要在原始實作跟生成的文件中來回閱讀。

昨天進展到這幾個 API 的實做時:

  • POST /bag/{bag_name}/tiddlers
  • POST /admin/bag_create_or_update

才開始研究 API 行為跟資料庫之間的互動具體是怎麼進行的。

我們可以看到 Tiddler (條目)實際上是被掛在 Bag (知識包)之下,並且有一個名為 Recipe 的抽象會決定一個 TiddlyWiki 最後要長怎樣。同時 MWS 存在這樣的 API:

這是一個類似 OverlayFS 的設計,POST "/recipe/:recipe_name/tiddlers",實際上是去找到最上層的 Bag 進行寫入。這個模型設計似乎是延續自 TiddlyWeb。

然而我看到這裡我就瞬間感覺使用 ACL 是一個錯誤的技術決策。Recipe 和 Bag 是多對多關係,同時 Tiddler 的操作界面是 Recipe 的視角看過去,這種關聯性授權 (Authorization) 控制應該由 ReBAC (Relationship-based access control) 處理。

況且我的目的是建立一個支援多租戶邏輯的 TiddlyWiki 的儲存實例,AuthN/AuthZ (Authentication/Authorization) 不是我首要關注的問題。

Footnotes

  1. TiddlyWiki in the Sky (or TiddlyWeb for TW5) - Pleasant Programmer. Retrieved 2026-05-30, from https://pleasantprogrammer.com/posts/tiddlywiki-in-the-sky-or-tiddlyweb-for-tw5.html

Wei Ji

前情提要

TiddlyRAG 的概念最早我在這篇文章就已經提出:

試著在本地運行嵌入模型:

調查幾個開源 LLM 應用程式在處理 RAG 相關的功能:

完成 POC Type-A,能夠匯入 TiddlyRAG 進行嵌入;並透過 MCP 進行檢索:

研究卡片化 (Zettelize) 的其中用例,將 Git Repo 轉換成文件,調查已經存在的實作,沒想到卻十分差勁:

完成 POC Type-B,試著從 deepwiki-open 抽出部份邏輯重新實做:

接著回到本文的重點。

僵化的資料探勘策略

deepwiki-open 的實作方式是採取兩步驟:

  1. 建立大綱(8~12頁)。
  2. 根據大綱對每一頁生成內容。

並且輸入是從事先把所有文件嵌入的資料中檢索 20 個檔案出來,但是我實際實驗發現在特定情況會有問題。

deepwiki-open 自己的 Repo 為例,它包含了 10 個不同語言的 README,因此使用 README 進行向量檢索的時候,會得到一堆不同語言的 README 跟一些 i18n 的檔案,完全不能作為有效的參考資訊。

所以我完成 POC Type-B 到一個程度後沒打算繼續深入,因為這明顯是一條死路。

根本性的原因是僵化的資料探勘策略,Git Repo 的不確定性很高:

  • 這是一個後端/後端/嵌入式系統/函式庫/UI 庫...專案?
  • 這是一個程式專案?
  • 這是一個文件專案?
  • 這是一個 Monorepo?
  • 這是很多 Microservice?
  • ...

需要一種非固定流水線 (Pipeline) 的機制才有可能處理這種不確定性。

決策算法

我的直覺是透過行為樹 (Behavior Tree) 建立兼富彈性與可控性的資料探勘策略,不過在正式使用以前,我需要調查一下同樣在遊戲界常用的演算法,因此有了以下幾篇文章:

GOAP 和 HTN 建立在 STRIPS 模型之上,而 STRIPS 模型要求建模者必須對環境有一定程度的理解與掌控,用在遊戲環境沒問題(環境是遊戲開發者自己設定的),但是在 Git Repo 這種不確定性高的環境就顯得不合適。

Utility AI 是另外一個遊戲常用的古典 AI,它的行為比較簡單我就沒有特別深入研究寫文章了,基本上運作機制很接近感知機 (Perceptron) 配上可客製化的激勵函數,所以它的可預測性與可解釋性比 BT (Behavior Tree) 來得差。

所以最後還是選擇行為樹。

行為樹生態

下一步是了解行為樹的生態,一個完整的行為樹解決方案應該包含以下條件:

  • Runtime SDK:用來運行 BT 的函式庫。
  • 序列化與反序列化:BT 能夠以檔案形式存在,而不是硬編碼在程式內。
  • 日誌:運行時紀錄下 BT 的狀態,用於事後除錯。
  • 靜態視覺化:能夠以 GUI 瀏覽 BT 檔案。
  • 動態視覺化:能夠以 GUI 遙測或模擬 BT。
  • 視覺化編輯器:能夠以 GUI 編輯行為樹。

話雖如此,但是不考慮遊戲引擎生態的,因為它們的 BT 通常和遊戲引擎高度整合。因此條件變得更嚴苛了,要滿足上述條件還要有解偶與高內聚的特性。

BehaviorTree.CPP 是唯一一個我找到滿足上述條件的,另外一個發現是看來 BT 不只是用在遊戲產業,(實體)機器人產業似乎也有使用。

BehaviorTree.CPP 生態系內一款名為 Groot 的視覺化工具,包含了編輯、遙測與回放功能,乍看之下似乎很完美。就算我另外用 Javascript 做,只要照著 Spec 刻就能對接 BehaviorTree.CPP 的生態系直接使用 Groot。

然而隨著深入調查卻發現不盡人意的事實,Groot 1 不再維護,且僅支援到 BehaviorTree.CPP 3.X (最新版為 4.9),Groot 2 則採取閉源訂閱制付費的方案。

接著我一邊看著 Mistreevous 的行為與文件,並和 BehaviorTree.CPP 的文件比較,我發現我無法理解「正宗行為樹」的邏輯應該為何。BehaviorTree.CPP 的文件經常使用反應式 (Reactive)、非同步 (Async)、觸發 (Trigger)、中斷 (Interrupt)...等詞彙,但是根據描述我又無法跟我平時開發 Javascript 的經驗做連結。

於是我花了一、兩天整理了行為樹的一些先備知識:

https://flyskypie.github.io/microproject-wikis/behavior-tree.html

簡單來說:

BT 最早是無狀態的,但是因為這樣每個 Cycle 都需要重新遍歷,這在大型或複雜的樹中會造成效能方面的問題。

於是狀態被引入了,讓下一個 Cycle 可以根據情況從特定的節點開始檢查,減少重複運算。但是這造成另外一個問題,當環境發生變化時,行為樹無法適應這些變化,因為一些檢查被跳過去了。

於是反應式 (Reactive)、非同步 (Async)、觸發 (Trigger)、中斷 (Interrupt) ...之類概念又被引入,用於抵銷狀態帶來的行為;可以在有狀態的 Scope 下條件性的觸發重新遍歷。

這個歷史過程造成了一些概念上的混淆,然而大部分 BT 教學並沒有提及這個過程。

如果不把一些觀念釐清(Cycle vs Tick, 有狀態 vs 無狀態),跟 LLM 討論會陷入雞同鴨講的情況。

Javascript 行為樹技術選型

調查了一下列一個清單:

behavior3js 乍看是聲望最高、又有視覺化工具,但是它自 2018 年以來就沒有更新了,而且文件十分凌亂,分散在程式碼內、GitHub Wiki、死掉的網站連結...內,所以我沒辦法快速的搞清楚這東西要怎麼使用。

BehaviorTree.js 則是 2021 年以來沒有更新,2026 年有推出支援 Typescript 的 beta 版本,不過缺乏生態系其他配套(特別是視覺化)。

Sutra.js 就算撇除作者在 Reddit 跟別人筆戰「Javascript 不需要型別」以外,README 映入眼簾的是 if/else 語法以及事件驅動等功能,完全背離 BT 的設計哲學。並且同樣缺乏 BT 視覺化配套。

Mistreevous 的聲望(星星數)並不是最高的,但是從文件的易用性、原生 Typescript 支援、視覺化的支援、序列化與反序列化的重視程度...綜合考量下是一個優秀的選擇。

具體差異我就不在這邊解釋,情況有一點複雜。

資料組織方案

這邊我要先補充一個關於 BT 的先備知識,BT 原則上是無狀態的,典型的 BT 會使用「黑板模式」:所有狀態是儲存在一個叫做黑板 (blackboard) 的東西裡面,簡單來說就是一個 key-value 表,然而實務上容易變成「一堆放在一起的全域變數」。

這對我而言是無法接受的,而碰巧我有使用 ECS (Entity-Component-System) 的經驗:

ECS 學習與 aimAndShoot 重構之旅

於是這件事就這樣自然發生了:

簡單來說 ECS 和 BT 都遵守 DoP (Data-oriented programming) 的哲學,因此資料跟邏輯本來就解偶了,所以把 ECS 中處理的資料的 EC 嫁接到 BT 上非常容易。

POC

結論,我完成了一個能夠在「深度優先遍歷」與「廣度優先遍歷」切換的 BT:

https://github.com/FlySkyPie/tiddlyrag-poc/tree/poc/type-c

「僵化的資料探勘策略」的問題並未完全解決,但是 POC 的策略就是一次只驗證一件事情,我認為完成一個具有彈性的 BT 框架就算完成階段性任務了。

下一步?

以下節錄自我的腦力激盪過程:

原始的設計期望透過「逐步探索」的方式來降低無用的資訊,並假設可以無須遍歷所有檔案,但是這個構想當中存在著謬誤。

LOD (Level of Development) 是在多媒體中經常被使用的技巧,白話來說就是越不重要的東西做得越粗糙,其中一個經典的應用就是遊戲場景,遠景時使用低面數、粗糙的模型,當攝影機拉近時再切換成高面數、精緻的模型。

但是即便如此,LOD 能夠運作的前提是先有一個高面數的理想模型,再對其進行降低面數的處理,因此低模存在的前提是該模型的細緻特徵已知。

再回過頭看原始「逐步探索」的設計就顯得不合理,因為資訊聚合 (Aggregation) 的前提是要先具備完整資訊。

最後我將這個問題定位為:探索與利用問題 (Exploration and Exploitation)。

我已經稍微回顧了:

  • 多臂老虎機問題 (multi-armed bandit problem, MAB)
  • ϵ-greedy
  • UCB (Upper Confidence Bound)
  • Thompson Sampling

下一步的重點可能會放在「如何用蒙地卡羅樹搜尋 (MCTS, Monte Carlo tree search) 解決這個資料探勘問題」上。

題外話

因為 TiddlyRAG 這個 Side Project 我也跑好一陣子了,最近想該給它一個正式的 LOGO 了,於是這張圖就產生了:

Footnotes

  1. Sutra.js - Fluent Behavior Trees for JavaScript Game Development : r/javascript. https://www.reddit.com/r/javascript/comments/194e8ef/