Compare commits
No commits in common. "master" and "separate-loop" have entirely different histories.
master
...
separate-l
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
agent:
|
agent:
|
||||||
character : hendrik # Directory name in agent/characters/<character>/
|
character : hendrik # Directory name in agent/characters/<character>/
|
||||||
max_iterations : 80 # step(s)
|
max_iterations : 40 # step(s)
|
||||||
max_history_chat : 0 # message(s) - 0 for unlimited (Dead Config)
|
max_history_chat : 0 # message(s) - 0 for unlimited (Dead Config)
|
||||||
max_tool_output : 0 # character(s) - 0 for unlimited
|
max_tool_output : 0 # character(s) - 0 for unlimited (Connected to AGENT_MAX_TOOL_OUTPUT but Dead Config)
|
||||||
|
|
||||||
user:
|
user:
|
||||||
id: # Fill your user UUID here, empty = TUI/MUC guest
|
id: # Fill your user UUID here, empty = TUI/MUC guest
|
||||||
@ -15,23 +15,26 @@ llm:
|
|||||||
moop: # Model Option
|
moop: # Model Option
|
||||||
db_path: "~/.config/hendrik/moop.sqlite3"
|
db_path: "~/.config/hendrik/moop.sqlite3"
|
||||||
|
|
||||||
asr:
|
asr: # Speech-to-Text (push-to-talk TUI). Model dikelola via moop tipe 'asr'.
|
||||||
record_dir : "~/.config/hendrik/recordings"
|
record_dir : "~/.config/hendrik/recordings" # lokasi file hasil rekaman
|
||||||
|
record_key : f9 # tombol push-to-talk (f1..f12 / ctrl-<huruf>)
|
||||||
record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows
|
record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows
|
||||||
device : default
|
device : default # nama device input
|
||||||
windows_mic : "" # if backend==windows: device dshow name, example: "Microphone Array (Realtek(R) Audio)"
|
windows_mic : "" # backend=windows: nama device dshow,
|
||||||
|
# contoh "Microphone Array (Realtek(R) Audio)"
|
||||||
format : wav # wav | ogg (opus)
|
format : wav # wav | ogg (opus)
|
||||||
sample_rate : 16000 # Hz
|
sample_rate : 16000 # Hz
|
||||||
channels : 1
|
channels : 1
|
||||||
max_seconds : 120 # second
|
max_seconds : 60 # auto-stop pengaman
|
||||||
transcribe_timeout : 60 # second
|
transcribe_timeout: 60 # detik timeout request ASR
|
||||||
keep_recordings : false
|
keep_recordings : true # false = hapus file setelah ditranskrip
|
||||||
auto_submit : true
|
auto_submit : false # true = hasil transkrip langsung dikirim
|
||||||
|
# false = hanya mengisi form (Ctrl+Enter)
|
||||||
|
|
||||||
session:
|
session: # Chat session
|
||||||
db_path: "~/.config/hendrik/sessions.json"
|
db_path: "~/.config/hendrik/sessions.json"
|
||||||
|
|
||||||
ragroleplay:
|
ragroleplay: # Use nomic-embed-text
|
||||||
db_path : "~/.config/hendrik/ragroleplay"
|
db_path : "~/.config/hendrik/ragroleplay"
|
||||||
vector_size : 768 # used on table create only
|
vector_size : 768 # used on table create only
|
||||||
|
|
||||||
@ -50,7 +53,7 @@ xmpp:
|
|||||||
# - "room2@conference.server"
|
# - "room2@conference.server"
|
||||||
nickname: "" # custom MUC nickname (empty = use username)
|
nickname: "" # custom MUC nickname (empty = use username)
|
||||||
selective_response: true # true = only response if mentioned/relevant
|
selective_response: true # true = only response if mentioned/relevant
|
||||||
debug: false # true = display debug log (stanza XML, lifecycle, etc)
|
debug: false # true = tampilkan log debug menyeluruh (semua stanza XML, lifecycle, dll)
|
||||||
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
|
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
|
||||||
|
|
||||||
telegram:
|
telegram:
|
||||||
|
|||||||
@ -58,8 +58,8 @@ ASR_MAX_SECONDS = int(_yaml_get("asr", "max_seconds", default=60))
|
|||||||
ASR_TIMEOUT = int(_yaml_get("asr", "transcribe_timeout", default=60))
|
ASR_TIMEOUT = int(_yaml_get("asr", "transcribe_timeout", default=60))
|
||||||
ASR_KEEP_RECORDINGS = _as_bool(_yaml_get("asr", "keep_recordings", default=True), default=True)
|
ASR_KEEP_RECORDINGS = _as_bool(_yaml_get("asr", "keep_recordings", default=True), default=True)
|
||||||
|
|
||||||
# Tombol push-to-talk dipatok tetap ke F9 (tidak bisa dikonfigurasi).
|
# Tombol push-to-talk: "f1".."f12" atau "ctrl-<huruf>". Default F9.
|
||||||
# Lihat interfaces/tui/actions.py -> ACTIONS["push_to_talk"].
|
ASR_RECORD_KEY = str(_yaml_get("asr", "record_key", default="f9")).strip().lower()
|
||||||
|
|
||||||
# true = hasil transkripsi langsung dikirim sebagai pesan,
|
# true = hasil transkripsi langsung dikirim sebagai pesan,
|
||||||
# false = hasil hanya disisipkan ke form input (user kirim sendiri via Ctrl+Enter).
|
# false = hasil hanya disisipkan ke form input (user kirim sendiri via Ctrl+Enter).
|
||||||
|
|||||||
2
hendrik
2
hendrik
@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# wrapper to run the hendrik agent from anywhere
|
# wrapper to run the TUI agent from anywhere
|
||||||
|
|
||||||
# Set HENDRIK_DIR env var to override, or update the default below
|
# Set HENDRIK_DIR env var to override, or update the default below
|
||||||
DEFAULT_DIR="/opt/hendrik"
|
DEFAULT_DIR="/opt/hendrik"
|
||||||
|
|||||||
@ -1,324 +0,0 @@
|
|||||||
# ERD / PlantUML / Buat
|
|
||||||
|
|
||||||
Berikut panduan cara membuat ERD dengan PlantUML.
|
|
||||||
|
|
||||||
Acuan format standarnya adalah `plan/milestone/moop.puml` (sumber) dan
|
|
||||||
`plan/milestone/moop.png` (hasil render). Ikuti gaya itu persis — header-nya sama,
|
|
||||||
tata letak entity-nya sama, cara menulis constraint dan relasinya juga sama.
|
|
||||||
|
|
||||||
Yang perlu disiapkan:
|
|
||||||
- prefix modul/domain (misal `moop`, `chat`, `memories`) — dipakai sebagai prefix nama tabel/entity
|
|
||||||
- daftar tabel beserta kolomnya
|
|
||||||
- relasi antar tabel (siapa mereferensi siapa, wajib atau boleh kosong)
|
|
||||||
- direktori tujuan untuk pasangan file `.puml` + `.png` (bawaan: `plan/milestone/`)
|
|
||||||
|
|
||||||
Bahasa penulisan: **isi diagram tetap bahasa Inggris** (nama tabel, tipe, dan teks
|
|
||||||
constraint seperti `on update cascade` / `null priority = disabled`) supaya konsisten
|
|
||||||
dengan skema SQL dan `moop.puml`. Penjelasan di dokumen ini saja yang bahasa Indonesia.
|
|
||||||
|
|
||||||
## Penamaan file dan lokasinya
|
|
||||||
|
|
||||||
- satu diagram = satu pasangan file: `<name>.puml` dan `<name>.png`
|
|
||||||
- file `.puml` dan `.png` diletakkan berdampingan di direktori yang sama
|
|
||||||
- nama diagram mengikuti nama fitur/modul, kebab-case atau huruf kecil biasa
|
|
||||||
(contoh: `moop.puml`, `2-basic-features.puml`)
|
|
||||||
- jangan pernah men-commit `.puml` tanpa `.png` hasil render-nya, dan render ulang setiap
|
|
||||||
kali `.puml` diubah
|
|
||||||
|
|
||||||
## Header wajib
|
|
||||||
|
|
||||||
Setiap ERD dibuka persis seperti ini. Tanpa judul, tanpa legenda, tanpa blok penulis:
|
|
||||||
|
|
||||||
```plantuml
|
|
||||||
@startuml
|
|
||||||
|
|
||||||
hide circle
|
|
||||||
skinparam linetype ortho
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
```
|
|
||||||
|
|
||||||
- `hide circle` menghapus lingkaran actor di depan nama entity.
|
|
||||||
- `skinparam linetype ortho` membuat garis relasi menyiku/lurus supaya diagram tetap enak dibaca.
|
|
||||||
- sisakan satu baris kosong setelah `@startuml` dan sebelum `@enduml`.
|
|
||||||
- jangan menambah `skinparam` lain, warna, atau blok `<style>`. File ERD tetap polos.
|
|
||||||
|
|
||||||
## Kelompokkan tabel dengan package
|
|
||||||
|
|
||||||
Cluster entity berdasarkan sub-domain memakai `package <nama> { ... }` (huruf kecil,
|
|
||||||
sedapatnya satu kata, contoh: `option`, `provider`). Semua entity dalam satu ranah
|
|
||||||
dimasukkan ke package yang sama, dan relasi ranah itu juga ditulis di dalam blok yang
|
|
||||||
sama. Antar package dipisah satu baris kosong.
|
|
||||||
|
|
||||||
```plantuml
|
|
||||||
package provider {
|
|
||||||
|
|
||||||
...entity dan relasi milik domain provider...
|
|
||||||
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Blok entity
|
|
||||||
|
|
||||||
Nama entity adalah nama tabelnya, apa adanya: berprefix nama modul, `snake_case`,
|
|
||||||
tunggal atau jamak mengikuti skema aslinya (contoh: `moop_model_set`, `moop_api`).
|
|
||||||
|
|
||||||
```plantuml
|
|
||||||
entity moop_model {
|
|
||||||
id : binary(40) <<PK>>
|
|
||||||
api : binary(40) <<FK>>
|
|
||||||
set : binary(40) <<FK>>
|
|
||||||
model : longtext
|
|
||||||
priority : int null
|
|
||||||
--
|
|
||||||
* api = moop_api.id
|
|
||||||
on update cascade
|
|
||||||
on delete cascade
|
|
||||||
* set = moop_model_set.id
|
|
||||||
on update cascade
|
|
||||||
on delete cascade
|
|
||||||
* null priority = disabled
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Aturannya:
|
|
||||||
- isi entity di-indent 4 spasi (kalau di dalam package: 8 spasi untuk atribut).
|
|
||||||
- satu atribut per baris, formatnya `nama : tipe` diikuti penanda stereotype.
|
|
||||||
- `--` memisahkan bagian kolom dari bagian constraint/aturan. Selalu tulis pemisahnya,
|
|
||||||
walaupun di bawahnya tidak ada apa-apa.
|
|
||||||
- tidak ada baris kosong di dalam blok entity.
|
|
||||||
- jangan pakai ekstensi `<<primary key>>`/`{mask}` dari PlantUML, jangan pakai warna,
|
|
||||||
jangan pakai alias (`entity "foo" as f`). Nama di sebelah kiri ya nama tabel aslinya.
|
|
||||||
|
|
||||||
### Sintaks atribut
|
|
||||||
|
|
||||||
| Kondisi | Sintaks | Contoh |
|
|
||||||
|---|---|---|
|
|
||||||
| primary key | `id : binary(40) <<PK>>` | selalu baris pertama, selalu `binary(40)` |
|
|
||||||
| foreign key | `api : binary(40) <<FK>>` | kolom FK juga `binary(40)` |
|
|
||||||
| kolom unique | `type : binary(40) unique <<FK>>` | `unique` ditulis tepat setelah tipe |
|
|
||||||
| boleh null | `priority : int null` | `null` ditulis setelah tipe |
|
|
||||||
| teks | `name : longtext` | |
|
|
||||||
| angka | `priority : int` | |
|
|
||||||
|
|
||||||
Tipe ditulis sebagai tipe SQL (`binary(40)`, `longtext`, `int`), bukan tipe Python.
|
|
||||||
Kunci surrogate dan semua referensi ke baris tabel lain memakai `binary(40)`.
|
|
||||||
|
|
||||||
### Bagian constraint
|
|
||||||
|
|
||||||
Setiap baris di bawah `--` diawali `* `. Aturan yang panjangnya lebih dari satu baris
|
|
||||||
dilanjutkan di baris berikutnya dengan indent lebih dalam, tanpa mengulang tanda `*`.
|
|
||||||
|
|
||||||
Tulis di sini, berurutan seperti ini:
|
|
||||||
1. Tujuan FK: `* <kolom> = <tabel_induk>.id`, lalu `on update cascade` dan
|
|
||||||
`on delete cascade` masing-masing di baris sendiri.
|
|
||||||
2. Aturan unique/komposit: `* unique(set, type)`.
|
|
||||||
3. Integritas antar kolom: `* set_type.type must equal type`.
|
|
||||||
4. Nilai yang diizinkan: `* type availability =` lalu daftar nilainya di baris
|
|
||||||
berikutnya, diberi kutip dan dipisah pipe — `"llm" | "embedding" | "image generation"`.
|
|
||||||
5. Catatan bisnis/semantik dengan kalimat biasa: `* null priority = disabled`,
|
|
||||||
`* empty key = no bearer`, `* one default set per model type`.
|
|
||||||
|
|
||||||
Bagian constraint inilah yang membuat diagram bisa di-review tanpa membaca SQL-nya.
|
|
||||||
Kalau ada aturan di skema atau di spesifikasi produk, tempatnya ada di sini.
|
|
||||||
|
|
||||||
## Relasi
|
|
||||||
|
|
||||||
Tulis setiap relasi di baris sendiri, tepat setelah blok entity yang memilikinya
|
|
||||||
(entity anak yang punya kolom FK, atau induknya kalau relasi itu memang dikenalkan dari
|
|
||||||
sana). Tanpa label, tanpa klausa `to`, tanpa baris kosong antara entity dan relasinya.
|
|
||||||
|
|
||||||
```plantuml
|
|
||||||
moop_model_set ||--|{ moop_model_set_type
|
|
||||||
moop_model_type ||-o{ moop_model_set_type
|
|
||||||
moop_model_type ||--|| moop_model_type_default
|
|
||||||
moop_model_set_type ||--o| moop_model_type_default
|
|
||||||
moop_api ||--o{ moop_key
|
|
||||||
```
|
|
||||||
|
|
||||||
Kardinalitas selalu `induk <--> anak`, memakai notasi crow-foot:
|
|
||||||
|
|
||||||
| Kiri (induk) | Arti | Kanan (anak) | Arti |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `\|\|` | tepat satu | `\|\|` | tepat satu |
|
|
||||||
| `\|\|` | tepat satu | `\|{` | satu atau lebih |
|
|
||||||
| `\|\|` | tepat satu | `o{` | nol atau lebih |
|
|
||||||
| `\|\|` | tepat satu | `o\|` | nol atau satu |
|
|
||||||
|
|
||||||
Simbolnya: `||` = wajib satu, `o|` = opsional satu, `|{` = satu atau lebih, `o{` = nol atau lebih.
|
|
||||||
|
|
||||||
**Jumlah dash bukan kardinalitas.** Yang membawa makna hanya karakter di kedua ujungnya
|
|
||||||
(`|`, `o`, `{`, `}`). Jumlah `-` adalah **knob tata letak yang mengatur orientasi**:
|
|
||||||
|
|
||||||
| Sintaks | Efek (sudah diverifikasi ke jar-nya) |
|
|
||||||
|---|---|
|
|
||||||
| `\|-` (satu dash) | **horizontal** — kedua entity berdampingan, edge-nya tidak menambah rank |
|
|
||||||
| `\|--` (dua dash) | **vertikal** — entity anak ditumpuk di bawah induknya. Ini bawaannya |
|
|
||||||
| `\|---`, `\|----` | tetap vertikal, cuma lebih panjang. Pakai saat garis saling menutupi |
|
|
||||||
|
|
||||||
Diukur pada rantai 3 entity yang identik: satu dash merender 543×95 (a→b→c berjajar),
|
|
||||||
dua dash merender 171×349 (a→b→c menurun). Menambah dash ke tiga atau empat hanya
|
|
||||||
menambah tinggi (275 → 336 px), tidak pernah mengubah orientasi.
|
|
||||||
|
|
||||||
Ini disengaja di `moop.puml`: `moop_model_type ||-o{ moop_model_set_type` memakai
|
|
||||||
**satu** dash supaya `moop_model_type` jatuh ke sebelah *kiri* tabel junction, sedangkan
|
|
||||||
semua relasi induk→anak memakai **dua** dash supaya diagram tetap mengalir dari atas ke bawah.
|
|
||||||
|
|
||||||
Jadi aturannya:
|
|
||||||
|
|
||||||
- pakai `--` sebagai default untuk semua relasi — diagramnya terbaca sebagai hierarki vertikal
|
|
||||||
- pindah ke satu `-` hanya kalau dua tabel memang perlu berdampingan agar render tidak kusut
|
|
||||||
- jangan pernah memakai jumlah dash untuk arti "kuat vs lemah" atau "identifying vs
|
|
||||||
non-identifying". Makna itu tidak ada, dan pembaca akan salah tafsir
|
|
||||||
- jangan pakai `..` (garis putus-putus). Hasilnya garis dashed dan tidak dipakai sama sekali
|
|
||||||
di format standar ini
|
|
||||||
|
|
||||||
## Menentukan posisi entity (mana yang atas/bawah, mana yang kiri/kanan)
|
|
||||||
|
|
||||||
Sisi penulisan entity pada baris relasi **adalah** posisinya. Entity di sisi kiri akan
|
|
||||||
ditempatkan **di atas** (relasi vertikal) atau **di sebelah kiri** (relasi horizontal)
|
|
||||||
dari entity di sisi kanan. Sudah diverifikasi ke jar-nya:
|
|
||||||
|
|
||||||
| Baris relasi | Hasil |
|
|
||||||
|---|---|
|
|
||||||
| `induk \|--\|{ anak` | `induk` di atas, `anak` di bawah |
|
|
||||||
| `anak }--\|\| induk` | `anak` di atas, `induk` di bawah (terbalik) |
|
|
||||||
| `induk \|-o{ anak` | `induk` di kiri, `anak` di kanan |
|
|
||||||
| `anak }o-\|\| induk` | `anak` di kiri, `induk` di kanan (terbalik) |
|
|
||||||
|
|
||||||
Dua konsekuensi yang penting:
|
|
||||||
|
|
||||||
1. **Urutan deklarasi `entity` di file tidak berpengaruh.** Menulis `anak` sebelum
|
|
||||||
`induk` tetap merender `induk` di atas, selama baris relasinya `induk ||--|{ anak`.
|
|
||||||
Jangan memperbaiki posisi dengan memindah-mindah blok entity — yang menggeser posisi
|
|
||||||
hanya baris relasi.
|
|
||||||
2. **Menulis barisnya terbalik akan membalik diagram dan merusak notasi.** Pertahankan
|
|
||||||
induk di sisi kiri, supaya crow's foot jatuh di sisi anak. Kalau karena alasan tata
|
|
||||||
letak anak harus muncul di atas induk, pindahkan *package/entity*-nya dan tetap tulis
|
|
||||||
`induk <--> anak`, jangan membalik baris relasinya.
|
|
||||||
|
|
||||||
Resep praktis supaya hasilnya enak dilihat:
|
|
||||||
|
|
||||||
- gambar hierarki utama dari atas ke bawah dengan `--`, induk selalu di kiri
|
|
||||||
- untuk tabel junction, pisahkan dua induknya: induk utama di atasnya dengan `||--|{`,
|
|
||||||
induk lookup di sampingnya dengan `||-o{` (satu dash) supaya induk itu jatuh ke kiri,
|
|
||||||
bukan menumpuk — persis kasus `moop_model_type`
|
|
||||||
- kalau dua entity sibling saling desak, beri satu dash tambahan pada salah satu
|
|
||||||
relasinya (`||---|{`) untuk membuka ruang vertikal
|
|
||||||
- kalau rantai tabelnya terlalu panjang sampai diagramnya terlalu tinggi, pecah per
|
|
||||||
sub-domain ke `package` sendiri — bukan dengan membalik baris relasi
|
|
||||||
|
|
||||||
Cek konsistensi yang wajib dilakukan:
|
|
||||||
- kalau kolom FK-nya `NOT NULL`, sisi anak harus memakai `|` (`||--|{`, `||--o|`)
|
|
||||||
- kalau kolom FK-nya nullable/opsional, sisi anak memakai `o` (`||-o{`, `||--o|`)
|
|
||||||
- kolom FK yang `UNIQUE` berarti relasi 1-ke-1 (`||--||` atau `||--o|`), bukan 1-ke-banyak
|
|
||||||
- setiap `<<FK>>` yang dideklarasikan di entity harus punya baris relasi yang cocok di
|
|
||||||
suatu tempat, dan setiap baris relasi harus punya kolom FK yang cocok. Tidak boleh ada
|
|
||||||
relasi yatim, tidak boleh ada FK yang tidak terdokumentasi.
|
|
||||||
|
|
||||||
## Relasi antar package
|
|
||||||
|
|
||||||
Kalau sebuah tabel di satu package mereferensi tabel di package lain, tetap deklarasikan
|
|
||||||
FK-nya di dalam entity anak dan gambar relasinya dari sisi anak, di dalam blok package
|
|
||||||
anaknya. Jangan membuat bagian "relations" terpisah di bawah file.
|
|
||||||
|
|
||||||
## Urutan penulisan
|
|
||||||
|
|
||||||
Aturan di bagian ini hanya untuk **keterbacaan sumbernya** — tidak mengubah tata letak
|
|
||||||
hasil render. Posisi ditentukan oleh baris relasi (lihat "Menentukan posisi entity").
|
|
||||||
|
|
||||||
- package: dari domain yang paling mandiri ke yang paling bergantung
|
|
||||||
- di dalam package: tabel induk dulu, lalu tabel junction/anaknya, lalu tabel yang
|
|
||||||
bergantung pada tabel-tabel itu
|
|
||||||
- atribut: `id` dulu, lalu FK dan kolom data, lalu kolom nullable/opsional paling akhir
|
|
||||||
|
|
||||||
## Render
|
|
||||||
|
|
||||||
Menggunakan jar yang sudah tersedia di `plan/milestone/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd plan/milestone && java -jar plantuml-mit-1.2026.8.jar -tpng -charset UTF-8 moop.puml -o .
|
|
||||||
```
|
|
||||||
|
|
||||||
- `-tpng` untuk keluaran PNG, `-charset UTF-8` supaya nilai enum yang dikutip tidak rusak
|
|
||||||
- `-o <dir>` menulis gambar di samping file sumbernya
|
|
||||||
- setelah render, buka `.png`-nya dan pastikan: diagramnya mengalir atas ke bawah, tidak
|
|
||||||
ada kotak yang tumpang tindih, tidak ada garis bersilangan yang sebenarnya bisa dihindari
|
|
||||||
dengan memindah satu relasi ke satu dash (berdampingan) atau menambah dash untuk membuka
|
|
||||||
ruang vertikal, setiap entity berada di tepat satu package, dan setiap relasi benar-benar
|
|
||||||
tersambung. Ingat: memindah blok `entity` di sumber **tidak** menggeser apa pun — yang
|
|
||||||
menggeser hanya baris relasi
|
|
||||||
|
|
||||||
## Template minimal
|
|
||||||
|
|
||||||
```plantuml
|
|
||||||
@startuml
|
|
||||||
|
|
||||||
hide circle
|
|
||||||
skinparam linetype ortho
|
|
||||||
|
|
||||||
package <domain> {
|
|
||||||
|
|
||||||
entity <prefix>_parent {
|
|
||||||
id : binary(40) <<PK>>
|
|
||||||
name : longtext
|
|
||||||
--
|
|
||||||
}
|
|
||||||
|
|
||||||
entity <prefix>_type {
|
|
||||||
id : binary(40) <<PK>>
|
|
||||||
type : longtext
|
|
||||||
--
|
|
||||||
* type availability =
|
|
||||||
"a" | "b" | "c"
|
|
||||||
}
|
|
||||||
|
|
||||||
entity <prefix>_parent_type {
|
|
||||||
id : binary(40) <<PK>>
|
|
||||||
parent : binary(40) <<FK>>
|
|
||||||
type : binary(40) <<FK>>
|
|
||||||
--
|
|
||||||
* parent = <prefix>_parent.id
|
|
||||||
on update cascade
|
|
||||||
on delete cascade
|
|
||||||
* type = <prefix>_type.id
|
|
||||||
on update cascade
|
|
||||||
on delete cascade
|
|
||||||
* unique(parent, type)
|
|
||||||
}
|
|
||||||
<prefix>_parent ||--|{ <prefix>_parent_type
|
|
||||||
<prefix>_type ||-o{ <prefix>_parent_type
|
|
||||||
|
|
||||||
entity <prefix>_child {
|
|
||||||
id : binary(40) <<PK>>
|
|
||||||
parent : binary(40) <<FK>>
|
|
||||||
priority : int null
|
|
||||||
--
|
|
||||||
* parent = <prefix>_parent.id
|
|
||||||
on update cascade
|
|
||||||
on delete cascade
|
|
||||||
* null priority = disabled
|
|
||||||
}
|
|
||||||
<prefix>_parent ||--o{ <prefix>_child
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Checklist review
|
|
||||||
|
|
||||||
- [ ] `@startuml` / `hide circle` / `skinparam linetype ortho` / `@enduml`, tidak ada yang lain di header
|
|
||||||
- [ ] semua entity dikelompokkan dalam `package`, tidak ada entity mengambang
|
|
||||||
- [ ] nama entity = nama tabel asli, berprefix modul, snake_case
|
|
||||||
- [ ] `id : binary(40) <<PK>>` jadi atribut pertama di setiap entity
|
|
||||||
- [ ] pemisah `--` ada di setiap entity
|
|
||||||
- [ ] kolom FK ditandai `<<FK>>` dan diulang di bagian constraint beserta aturan cascade-nya
|
|
||||||
- [ ] nilai enum/domain diberi kutip dan dipisah pipe
|
|
||||||
- [ ] setiap relasi di baris sendiri, tepat setelah entity-nya, induk di sisi kiri
|
|
||||||
- [ ] kardinalitas cocok dengan nullability dan uniqueness FK
|
|
||||||
- [ ] jumlah dash hanya dipakai sebagai knob tata letak (`-` = berdampingan, `--` = menumpuk), bukan sebagai makna. Tanpa `..`, tanpa tafsir "kuat vs lemah"
|
|
||||||
- [ ] induk selalu ditulis di sisi kiri setiap baris relasi, supaya dia yang muncul di atas / di kiri
|
|
||||||
- [ ] `.png` hasil render ada di samping `.puml` dan hasilnya bersih secara visual
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
# ERD
|
|
||||||
|
|
||||||
Berikut daftar instruction yang bisa kamu panggil.
|
|
||||||
|
|
||||||
Pembuatan Diagram
|
|
||||||
- `erd-plantuml-create.md` Membuat ERD (Entity Relationship Diagram) dengan PlantUML sesuai format standar
|
|
||||||
@ -16,7 +16,6 @@ from .keycodes import (
|
|||||||
KEY_F2,
|
KEY_F2,
|
||||||
KEY_F4,
|
KEY_F4,
|
||||||
KEY_F6,
|
KEY_F6,
|
||||||
KEY_F9,
|
|
||||||
)
|
)
|
||||||
from .input import (
|
from .input import (
|
||||||
new_session_popup,
|
new_session_popup,
|
||||||
@ -30,8 +29,11 @@ from .moopui import (
|
|||||||
providers_manage_popup,
|
providers_manage_popup,
|
||||||
)
|
)
|
||||||
from .agent import submit, log
|
from .agent import submit, log
|
||||||
|
from .keycodes import resolve_key
|
||||||
from . import talk as talk_mod
|
from . import talk as talk_mod
|
||||||
|
|
||||||
|
import config as _config
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- handlers ---
|
# ---------------------------------------------------------------- handlers ---
|
||||||
def _exit_app(app, stdscr):
|
def _exit_app(app, stdscr):
|
||||||
@ -39,13 +41,9 @@ def _exit_app(app, stdscr):
|
|||||||
|
|
||||||
|
|
||||||
def _cancel_inference(app, stdscr):
|
def _cancel_inference(app, stdscr):
|
||||||
# Hanya menandai + membangunkan stream; penghentian loop dan pembersihan
|
app.llm.cancel_requested = True
|
||||||
# log dilakukan oleh agent thread sendiri (CursesObserver.on_cancelled),
|
app.agent_done.set()
|
||||||
# supaya tidak ada balapan tulis terhadap app.log dari dua thread.
|
log(app, "system", " Inference cancelled by user")
|
||||||
# `agent_done` sengaja tidak di-set: main loop harus menunggu thread agen
|
|
||||||
# benar-benar berhenti, bukan berpura-pura selesai lalu mulai prompt baru
|
|
||||||
# di atas messages yang sedang ditulisnya.
|
|
||||||
app.llm.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
def _send_prompt(app, stdscr):
|
def _send_prompt(app, stdscr):
|
||||||
@ -57,8 +55,8 @@ def _record_talk(app, stdscr):
|
|||||||
|
|
||||||
|
|
||||||
def _talk_configured(app):
|
def _talk_configured(app):
|
||||||
# Tombol rekam dipatok tetap (F9) sehingga selalu valid. Ketersediaan
|
# Tombol rekam valid (di-resolve dari config). Ketersediaan chain 'asr'
|
||||||
# chain 'asr' divalidasi di handler supaya bisa memberi pesan panduan.
|
# divalidasi di handler supaya bisa memberi pesan panduan, bukan diam.
|
||||||
return getattr(app, "talk_key", None) is not None
|
return getattr(app, "talk_key", None) is not None
|
||||||
|
|
||||||
|
|
||||||
@ -120,12 +118,12 @@ ACTIONS = {
|
|||||||
"label": "Change Workspace", "mnemonic": "C", "shortcut": "F6",
|
"label": "Change Workspace", "mnemonic": "C", "shortcut": "F6",
|
||||||
"handler": workspace_popup, "enabled": _ready,
|
"handler": workspace_popup, "enabled": _ready,
|
||||||
},
|
},
|
||||||
# Push-to-Talk (ASR): tombol dipatok tetap F9 (tidak dikonfigurasi).
|
# Push-to-Talk (ASR): tombol berasal dari config.asr.record_key (default F9).
|
||||||
# Handler talk_toggle memvalidasi chain moop 'asr' + ketersediaan mic.
|
# Handler talk_toggle memvalidasi chain moop 'asr' + ketersediaan mic.
|
||||||
"push_to_talk": {
|
"push_to_talk": {
|
||||||
"key": KEY_F9,
|
"key": resolve_key(getattr(_config, "ASR_RECORD_KEY", "f9")),
|
||||||
"label": "Push-to-Talk", "mnemonic": "T",
|
"label": "Push-to-Talk", "mnemonic": "T",
|
||||||
"shortcut": "F9",
|
"shortcut": (getattr(_config, "ASR_RECORD_KEY", "f9") or "").upper(),
|
||||||
"handler": _record_talk,
|
"handler": _record_talk,
|
||||||
"enabled": lambda app: _ready(app) and _talk_configured(app),
|
"enabled": lambda app: _ready(app) and _talk_configured(app),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,14 +27,6 @@ def _persist_msg(app, message):
|
|||||||
app.current_session.doc_id, message["role"], message["content"], **extra
|
app.current_session.doc_id, message["role"], message["content"], **extra
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _truncate_msg(app, count):
|
|
||||||
"""Buang `count` pesan terakhir dari DB session (dipanggil saat turn
|
|
||||||
dibatalkan), supaya riwayat tersimpan identik dengan app.messages."""
|
|
||||||
if not app.current_session or count <= 0:
|
|
||||||
return
|
|
||||||
app.session_mgr.remove_last_messages(app.current_session.doc_id, count)
|
|
||||||
|
|
||||||
WELCOME_ART = """
|
WELCOME_ART = """
|
||||||
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
|
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
|
||||||
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
|
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
|
||||||
@ -64,7 +56,7 @@ WELCOME_ART = """
|
|||||||
╚══════════════════════════════════════════╝
|
╚══════════════════════════════════════════╝
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def log(app, role, text, model_info=None, usage=None):
|
def log(app, role, text, model_info=None):
|
||||||
entry = {
|
entry = {
|
||||||
"role": role,
|
"role": role,
|
||||||
"text": text,
|
"text": text,
|
||||||
@ -73,9 +65,6 @@ def log(app, role, text, model_info=None, usage=None):
|
|||||||
if model_info:
|
if model_info:
|
||||||
# Display pair: (provider, model)
|
# Display pair: (provider, model)
|
||||||
entry["model_info"] = model_info
|
entry["model_info"] = model_info
|
||||||
if usage:
|
|
||||||
# Token pemakaian: prompt/completion/total dari provider
|
|
||||||
entry["usage"] = usage
|
|
||||||
app.log.append(entry)
|
app.log.append(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@ -87,23 +76,20 @@ def _pair(model_info):
|
|||||||
return (model_info.get("provider"), model_info.get("model"))
|
return (model_info.get("provider"), model_info.get("model"))
|
||||||
|
|
||||||
|
|
||||||
def _tag_ai_logs(app, prev_entries, pair, usage=None):
|
def _tag_ai_logs(app, prev_entries, pair):
|
||||||
"""Tempelkan info model (dan token usage) yang benar-benar dipakai ke entri
|
"""Tempelkan info model yang benar-benar dipakai ke entri log AI yang baru
|
||||||
log AI yang baru dibuat pada langkah ini.
|
dibuat pada langkah ini.
|
||||||
|
|
||||||
Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah
|
Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah
|
||||||
llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries`
|
llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries`
|
||||||
menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python.
|
menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python.
|
||||||
"""
|
"""
|
||||||
if not pair and not usage:
|
if not pair:
|
||||||
return
|
return
|
||||||
seen = {id(e) for e in prev_entries}
|
seen = {id(e) for e in prev_entries}
|
||||||
for entry in app.log:
|
for entry in app.log:
|
||||||
if entry.get("role") == "ai" and id(entry) not in seen:
|
if entry.get("role") == "ai" and id(entry) not in seen:
|
||||||
if pair:
|
|
||||||
entry["model_info"] = pair
|
entry["model_info"] = pair
|
||||||
if usage:
|
|
||||||
entry["usage"] = usage
|
|
||||||
|
|
||||||
|
|
||||||
class CursesObserver(agent_loop.AgentObserver):
|
class CursesObserver(agent_loop.AgentObserver):
|
||||||
@ -123,13 +109,10 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.pair = None
|
self.pair = None
|
||||||
self.usage = None # token pemakaian langkah LLM terakhir
|
|
||||||
self._buf = []
|
self._buf = []
|
||||||
self._placeholder = None
|
self._placeholder = None
|
||||||
self._stamp = None
|
self._stamp = None
|
||||||
self._stamp_step = None
|
self._stamp_step = None
|
||||||
self._tool_total = 0 # jumlah tool call yang dijadwalkan langkah ini
|
|
||||||
self._tool_seen = 0 # tool call yang sudah di-log langkah ini
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- lifecycle ---
|
# ------------------------------------------------------------- lifecycle ---
|
||||||
def on_run_start(self):
|
def on_run_start(self):
|
||||||
@ -143,9 +126,6 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
self._stamp_step = ntro.start()
|
self._stamp_step = ntro.start()
|
||||||
self._buf = []
|
self._buf = []
|
||||||
self._placeholder = None
|
self._placeholder = None
|
||||||
self.usage = None
|
|
||||||
self._tool_total = 0
|
|
||||||
self._tool_seen = 0
|
|
||||||
log(self.app, "system", f" step {step + 1} \u2014 Thinking...")
|
log(self.app, "system", f" step {step + 1} \u2014 Thinking...")
|
||||||
# Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut
|
# Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut
|
||||||
# terjaga dan id()-nya tidak dipakai ulang saat entri itu di-pop.
|
# terjaga dan id()-nya tidak dipakai ulang saat entri itu di-pop.
|
||||||
@ -155,11 +135,8 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
def on_step_end(self, step):
|
def on_step_end(self, step):
|
||||||
ntro.end(self._stamp_step)
|
ntro.end(self._stamp_step)
|
||||||
|
|
||||||
def on_response(self, model_info, warning, usage=None):
|
def on_response(self, model_info, warning):
|
||||||
self.pair = _pair(model_info)
|
self.pair = _pair(model_info)
|
||||||
# Token pemakaian langkah LLM ini (untuk label usage di entri ai).
|
|
||||||
# Total satu turn sudah diakumulasi engine di AgentResult.usage.
|
|
||||||
self.usage = usage
|
|
||||||
self._pop_thinking()
|
self._pop_thinking()
|
||||||
if warning:
|
if warning:
|
||||||
log(self.app, "system", f" {warning}")
|
log(self.app, "system", f" {warning}")
|
||||||
@ -178,22 +155,15 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
# Narasi assistant sebelum tools; placeholder streaming dibuang karena
|
# Narasi assistant sebelum tools; placeholder streaming dibuang karena
|
||||||
# konten yang sama akan di-log utuh di sini.
|
# konten yang sama akan di-log utuh di sini.
|
||||||
self._drop_placeholder()
|
self._drop_placeholder()
|
||||||
# Baris token satu panggilan LLM dititipkan ke tool_call TERAKHIR dari
|
|
||||||
# langkah ini (lihat on_tool_call), supaya urutan render tetap benar.
|
|
||||||
self._tool_total = len(names)
|
|
||||||
self._tool_seen = 0
|
|
||||||
if content and content.strip():
|
if content and content.strip():
|
||||||
log(self.app, "ai", content, model_info=self.pair, usage=self.usage)
|
log(self.app, "ai", content, model_info=self.pair)
|
||||||
_tag_ai_logs(self.app, self._log_prev, self.pair)
|
_tag_ai_logs(self.app, self._log_prev, self.pair)
|
||||||
|
|
||||||
def on_tool_call(self, name, arguments):
|
def on_tool_call(self, name, arguments):
|
||||||
entry = log(self.app, "tool_call", json.dumps({
|
log(self.app, "tool_call", json.dumps({
|
||||||
"name": name,
|
"name": name,
|
||||||
"arguments": arguments,
|
"arguments": arguments,
|
||||||
}), model_info=self.pair)
|
}), model_info=self.pair)
|
||||||
self._tool_seen += 1
|
|
||||||
if self.usage and self._tool_seen >= self._tool_total:
|
|
||||||
entry["usage"] = self.usage
|
|
||||||
self._follow()
|
self._follow()
|
||||||
|
|
||||||
# ---------------------------------------------------------------- final ---
|
# ---------------------------------------------------------------- final ---
|
||||||
@ -206,25 +176,15 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if result.is_multimodal:
|
if result.is_multimodal:
|
||||||
entry = log(self.app, "ai", "[Image generated]",
|
log(self.app, "ai", "[Image generated]", model_info=self.pair)
|
||||||
model_info=self.pair, usage=result.usage)
|
|
||||||
elif content is None:
|
elif content is None:
|
||||||
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
|
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
|
||||||
entry = None
|
pass
|
||||||
elif self._placeholder is None and content:
|
elif self._placeholder is None and content:
|
||||||
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
|
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
|
||||||
# delta) — pastikan teks tetap terlihat.
|
# delta) — pastikan teks tetap terlihat.
|
||||||
entry = log(self.app, "ai", content,
|
log(self.app, "ai", content, model_info=self.pair)
|
||||||
model_info=self.pair, usage=result.usage)
|
# placeholder streaming tidak perlu diisi ulang: isinya sudah teks final
|
||||||
else:
|
|
||||||
# placeholder streaming tidak perlu diisi ulang: isinya sudah teks
|
|
||||||
# final. Gunakan sebagai target tempelan usage.
|
|
||||||
entry = self._placeholder
|
|
||||||
|
|
||||||
# Total token satu turn (akumulasi semua langkah LLM) ditaruh di
|
|
||||||
# entri respons final, dirender setelah teks pesan.
|
|
||||||
if entry is not None and result.usage:
|
|
||||||
entry["usage"] = result.usage
|
|
||||||
|
|
||||||
_tag_ai_logs(self.app, self._log_prev, self.pair)
|
_tag_ai_logs(self.app, self._log_prev, self.pair)
|
||||||
log(self.app, "sep", "")
|
log(self.app, "sep", "")
|
||||||
@ -232,23 +192,6 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
def on_max_iterations(self, max_iterations=None):
|
def on_max_iterations(self, max_iterations=None):
|
||||||
log(self.app, "error", "Max iterations reached without final answer.")
|
log(self.app, "error", "Max iterations reached without final answer.")
|
||||||
|
|
||||||
def on_cancelled(self, result):
|
|
||||||
"""Turn dihentikan user. Berjalan di thread agen — satu-satunya writer
|
|
||||||
app.log, jadi tidak ada balapan dengan main loop."""
|
|
||||||
self._pop_thinking()
|
|
||||||
# Sisa turn dibuang dari konteks (assistant parsial dengan tool_calls
|
|
||||||
# tanpa hasil = payload tidak valid), tapi teks yang sudah terbaca tetap
|
|
||||||
# ditampilkan supaya user tidak kehilangan apa yang sudah dihasilkan.
|
|
||||||
partial = result.partial or ""
|
|
||||||
self._drop_placeholder()
|
|
||||||
if partial.strip():
|
|
||||||
entry = log(self.app, "ai", partial, model_info=self.pair,
|
|
||||||
usage=result.usage)
|
|
||||||
entry["cancelled"] = True
|
|
||||||
log(self.app, "system", " Inference cancelled by user")
|
|
||||||
log(self.app, "sep", "")
|
|
||||||
self._follow()
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- utils ---
|
# ----------------------------------------------------------------- utils ---
|
||||||
def _follow(self):
|
def _follow(self):
|
||||||
if self.app.scroll_follow:
|
if self.app.scroll_follow:
|
||||||
@ -276,9 +219,7 @@ def _run_agent(app):
|
|||||||
app.messages,
|
app.messages,
|
||||||
on_append=lambda msg: _persist_msg(app, msg),
|
on_append=lambda msg: _persist_msg(app, msg),
|
||||||
keep_model_info=True,
|
keep_model_info=True,
|
||||||
on_truncate=lambda n: _truncate_msg(app, n),
|
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
agent_loop.run_agent_loop(
|
agent_loop.run_agent_loop(
|
||||||
store,
|
store,
|
||||||
app.llm,
|
app.llm,
|
||||||
@ -288,11 +229,6 @@ def _run_agent(app):
|
|||||||
observer=CursesObserver(app),
|
observer=CursesObserver(app),
|
||||||
tool_reminder=None,
|
tool_reminder=None,
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
# Selalu tandai selesai, meskipun engine melempar. Tanpa ini UI bisa
|
|
||||||
# terkunci di PROCESSING selamanya kalau ada exception tak terduga
|
|
||||||
# (mis. saat pembatalan).
|
|
||||||
app.agent_done.set()
|
|
||||||
|
|
||||||
|
|
||||||
def submit(app, stdscr):
|
def submit(app, stdscr):
|
||||||
@ -321,9 +257,6 @@ def submit(app, stdscr):
|
|||||||
|
|
||||||
_add_msg(app, "user", query)
|
_add_msg(app, "user", query)
|
||||||
|
|
||||||
# Turn baru: bersihkan state cancel sebelumnya supaya cancel yang tertunda
|
|
||||||
# tidak membatalkan prompt yang baru dikirim.
|
|
||||||
app.llm.begin_turn()
|
|
||||||
app.agent_done.clear()
|
app.agent_done.clear()
|
||||||
app.agent_thread = threading.Thread(
|
app.agent_thread = threading.Thread(
|
||||||
target=_run_agent,
|
target=_run_agent,
|
||||||
|
|||||||
@ -60,7 +60,7 @@ class HendrikTUI:
|
|||||||
self.talk_done = threading.Event()
|
self.talk_done = threading.Event()
|
||||||
self._talk_thread: threading.Thread | None = None
|
self._talk_thread: threading.Thread | None = None
|
||||||
self._talk_result: dict | None = None
|
self._talk_result: dict | None = None
|
||||||
# Kode tombol rekam: dipatok tetap F9 (lihat ACTIONS["push_to_talk"])
|
# Kode tombol rekam dari config (None = tombol tidak valid/fitur mati)
|
||||||
from .actions import ACTIONS as _ACTIONS
|
from .actions import ACTIONS as _ACTIONS
|
||||||
self.talk_key = _ACTIONS["push_to_talk"]["key"]
|
self.talk_key = _ACTIONS["push_to_talk"]["key"]
|
||||||
self.talk_label = _ACTIONS["push_to_talk"]["shortcut"]
|
self.talk_label = _ACTIONS["push_to_talk"]["shortcut"]
|
||||||
@ -214,21 +214,16 @@ class HendrikTUI:
|
|||||||
pair = (mi.get("provider"), mi.get("model")) if mi else None
|
pair = (mi.get("provider"), mi.get("model")) if mi else None
|
||||||
# Log tool calls jika ada
|
# Log tool calls jika ada
|
||||||
if msg.get("tool_calls"):
|
if msg.get("tool_calls"):
|
||||||
tcl = msg["tool_calls"]
|
for tc in msg["tool_calls"]:
|
||||||
for j, tc in enumerate(tcl):
|
|
||||||
# Baris token langkah ini dititipkan di tool_call terakhir
|
|
||||||
# (konvensi sama dengan CursesObserver.on_tool_call).
|
|
||||||
last = (j == len(tcl) - 1)
|
|
||||||
log(self, "tool_call", json.dumps({
|
log(self, "tool_call", json.dumps({
|
||||||
"name": tc["function"]["name"],
|
"name": tc["function"]["name"],
|
||||||
"arguments": tc["function"]["arguments"],
|
"arguments": tc["function"]["arguments"],
|
||||||
}), model_info=pair,
|
}), model_info=pair)
|
||||||
usage=msg.get("usage") if last else None)
|
|
||||||
# Log AI content jika ada dan ini adalah response final
|
# Log AI content jika ada dan ini adalah response final
|
||||||
next_role = session.messages[i + 1]["role"] if i + 1 < len(session.messages) else None
|
next_role = session.messages[i + 1]["role"] if i + 1 < len(session.messages) else None
|
||||||
if next_role in ("user", None):
|
if next_role in ("user", None):
|
||||||
if msg.get("content"):
|
if msg.get("content"):
|
||||||
log(self, "ai", msg["content"], usage=msg.get("usage"))
|
log(self, "ai", msg["content"])
|
||||||
if pair:
|
if pair:
|
||||||
self.log[-1]["model_info"] = pair
|
self.log[-1]["model_info"] = pair
|
||||||
elif msg["role"] == "tool":
|
elif msg["role"] == "tool":
|
||||||
@ -289,9 +284,8 @@ class HendrikTUI:
|
|||||||
key = keycodes.read_key(stdscr, timeout_ms)
|
key = keycodes.read_key(stdscr, timeout_ms)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
if self.processing:
|
if self.processing:
|
||||||
# Sama seperti Esc: cukup minta pembatalan, biarkan thread
|
self.llm.cancel_requested = True
|
||||||
# agen yang membersihkan log lalu menandai dirinya selesai.
|
self.agent_done.set()
|
||||||
self.llm.cancel()
|
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
key = -1
|
key = -1
|
||||||
|
|||||||
@ -263,6 +263,30 @@ def read_key(stdscr, timeout_ms: int = -1) -> int:
|
|||||||
return KEY_ESC
|
return KEY_ESC
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_key(name) -> int | None:
|
||||||
|
"""Petakan nama tombol dari config menjadi kode kunci.
|
||||||
|
|
||||||
|
Format: "f1".."f12", "ctrl-<huruf>", atau "esc"/"enter".
|
||||||
|
Mengembalikan None jika tidak dikenal."""
|
||||||
|
n = str(name or "").strip().lower().replace("+", "-").replace(" ", "")
|
||||||
|
if not n:
|
||||||
|
return None
|
||||||
|
_F_KEYS = {f"f{i}": k for i, k in enumerate(
|
||||||
|
[KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6,
|
||||||
|
KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12], start=1)}
|
||||||
|
if n in _F_KEYS:
|
||||||
|
return _F_KEYS[n]
|
||||||
|
if n == "esc":
|
||||||
|
return KEY_ESC
|
||||||
|
if n == "enter":
|
||||||
|
return 13
|
||||||
|
if n.startswith("ctrl-") and len(n) == 6:
|
||||||
|
ch = n[-1]
|
||||||
|
if "a" <= ch <= "z":
|
||||||
|
return ord(ch) & 0x1F
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_alt_key(key: int) -> bool:
|
def is_alt_key(key: int) -> bool:
|
||||||
return _KEY_ALT_BASE <= key <= _KEY_ALT_BASE + 126
|
return _KEY_ALT_BASE <= key <= _KEY_ALT_BASE + 126
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,6 @@ C_INPUT_BORDER = theme.C_INPUT_BORDER
|
|||||||
C_STATUS_INFO = theme.C_STATUS_INFO
|
C_STATUS_INFO = theme.C_STATUS_INFO
|
||||||
C_WELCOME = theme.C_WELCOME
|
C_WELCOME = theme.C_WELCOME
|
||||||
C_TOOL_CALL = theme.C_TOOL_CALL
|
C_TOOL_CALL = theme.C_TOOL_CALL
|
||||||
C_USAGE = theme.C_USAGE
|
|
||||||
|
|
||||||
|
|
||||||
def draw(app, stdscr):
|
def draw(app, stdscr):
|
||||||
@ -69,35 +68,6 @@ def _model_info_line(item):
|
|||||||
return f" {m_name} " if m_name else ""
|
return f" {m_name} " if m_name else ""
|
||||||
|
|
||||||
|
|
||||||
def _fmt_tokens(n):
|
|
||||||
"""1234 -> '1.2k', 999 -> '999'."""
|
|
||||||
if n >= 1000:
|
|
||||||
return f"{n / 1000:.1f}k".replace(".0k", "k")
|
|
||||||
return str(n)
|
|
||||||
|
|
||||||
|
|
||||||
def _usage_line(item, label="tokens"):
|
|
||||||
"""Baris token usage dari entri log; "" jika tidak ada.
|
|
||||||
|
|
||||||
Contoh: ' tokens in 48.2k → out 260 · total 48.5k · ctx 48.2k '.
|
|
||||||
`ctx` = ukuran context window saat itu (prompt tokens panggilan terakhir;
|
|
||||||
pada riwayat lama tanpa data itu, jatuh ke total in).
|
|
||||||
`label` membedakan cakupan angka: 'tokens' untuk total satu turn (blok ai
|
|
||||||
final), 'step tokens' untuk satu panggilan LLM (blok tool_call)."""
|
|
||||||
usage = item.get("usage")
|
|
||||||
if not isinstance(usage, dict):
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
pt = int(usage.get("prompt_tokens") or 0)
|
|
||||||
ct = int(usage.get("completion_tokens") or 0)
|
|
||||||
tt = int(usage.get("total_tokens") or (pt + ct))
|
|
||||||
ctx = int(usage.get("context_tokens") or pt or tt)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return ""
|
|
||||||
return (f" {label} in {_fmt_tokens(pt)} → out {_fmt_tokens(ct)}"
|
|
||||||
f" · total {_fmt_tokens(tt)} · ctx {_fmt_tokens(ctx)} ")
|
|
||||||
|
|
||||||
|
|
||||||
def draw_chat(app, stdscr):
|
def draw_chat(app, stdscr):
|
||||||
# Area chat — dari baris 2 sampai baris (h - 11).
|
# Area chat — dari baris 2 sampai baris (h - 11).
|
||||||
# Bisa di-scroll dengan Page Up / Page Down.
|
# Bisa di-scroll dengan Page Up / Page Down.
|
||||||
@ -186,19 +156,9 @@ def draw_chat(app, stdscr):
|
|||||||
if info_line:
|
if info_line:
|
||||||
_add_row([(C_AI, info_line, False)])
|
_add_row([(C_AI, info_line, False)])
|
||||||
|
|
||||||
# Teks parsial dari turn yang dibatalkan: diberi penanda supaya
|
|
||||||
# user tahu ini bukan jawaban lengkap (dan tidak ikut dikirim
|
|
||||||
# sebagai konteks pada prompt berikutnya).
|
|
||||||
if item.get("cancelled"):
|
|
||||||
label = f" {app.character_name} ({item['time']}) \u2717 cancelled "
|
|
||||||
else:
|
|
||||||
label = f" {app.character_name} ({item['time']}) "
|
label = f" {app.character_name} ({item['time']}) "
|
||||||
_add_row([(C_AI, label)])
|
_add_row([(C_AI, label)])
|
||||||
_wrap_render(text, indent=1, color=C_INPUT, bold=False)
|
_wrap_render(text, indent=1, color=C_INPUT, bold=False)
|
||||||
# Baris token usage setelah teks pesan respons
|
|
||||||
usage_line = _usage_line(item)
|
|
||||||
if usage_line:
|
|
||||||
_add_row([(C_USAGE, usage_line, False)])
|
|
||||||
elif role == "system":
|
elif role == "system":
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
_add_row([(C_SYSTEM, lines[0])])
|
_add_row([(C_SYSTEM, lines[0])])
|
||||||
@ -248,11 +208,6 @@ def draw_chat(app, stdscr):
|
|||||||
(C_TOOL_CALL, "unknown"),
|
(C_TOOL_CALL, "unknown"),
|
||||||
(C_AI, f" ({item['time']}) "),
|
(C_AI, f" ({item['time']}) "),
|
||||||
])
|
])
|
||||||
# Baris token panggilan LLM yang menghasilkan tool call ini,
|
|
||||||
# ditempel di tool_call terakhir langkah (lihat agent.CursesObserver).
|
|
||||||
usage_line = _usage_line(item, label="step tokens")
|
|
||||||
if usage_line:
|
|
||||||
_add_row([(C_USAGE, usage_line, False)])
|
|
||||||
# Blank line di bawah
|
# Blank line di bawah
|
||||||
_add_blank()
|
_add_blank()
|
||||||
elif role == "error":
|
elif role == "error":
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# talk.py — Push-to-Talk (ASR/STT) untuk TUI.
|
# talk.py — Push-to-Talk (ASR/STT) untuk TUI.
|
||||||
#
|
#
|
||||||
# Alur:
|
# Alur:
|
||||||
# * F9 (dipatok, tidak configurable) → mulai rekam (ffmpeg subprocess, non-blocking).
|
# * F9 (configurable) → mulai rekam (ffmpeg subprocess, non-blocking).
|
||||||
# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil
|
# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil
|
||||||
# disisipkan ke form input pada posisi kursor,
|
# disisipkan ke form input pada posisi kursor,
|
||||||
# TIDAK submit (default).
|
# TIDAK submit (default).
|
||||||
@ -25,13 +25,12 @@ from tools import asr as asr_mod
|
|||||||
from .agent import log, submit
|
from .agent import log, submit
|
||||||
|
|
||||||
|
|
||||||
# Tombol push-to-talk dipatok tetap ke F9 (lihat actions.py & keycodes.py).
|
|
||||||
TALK_SHORTCUT_LABEL = "F9"
|
|
||||||
|
|
||||||
|
|
||||||
def _shortcut_label():
|
def _shortcut_label():
|
||||||
"""Label tombol rekam untuk pesan UI (selalu 'F9')."""
|
"""Label tombol rekam untuk pesan UI (dari config.asr.record_key)."""
|
||||||
return TALK_SHORTCUT_LABEL
|
n = (config.ASR_RECORD_KEY or "f9").strip().lower()
|
||||||
|
if n.startswith("ctrl-") and len(n) == 6:
|
||||||
|
return f"Ctrl+{n[5].upper()}"
|
||||||
|
return n.upper()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ helpers ---
|
# ------------------------------------------------------------------ helpers ---
|
||||||
|
|||||||
@ -21,7 +21,6 @@ C_HINT_DISABLED = 13 # hint disabled (abu-abu)
|
|||||||
C_WELCOME = 14 # welcome art: light blue
|
C_WELCOME = 14 # welcome art: light blue
|
||||||
C_TOOL_CALL = 15 # tool call: kuning terang
|
C_TOOL_CALL = 15 # tool call: kuning terang
|
||||||
C_TOOL_RESULT = 16 # tool result: magenta muda
|
C_TOOL_RESULT = 16 # tool result: magenta muda
|
||||||
C_USAGE = 22 # token usage: abu-abu
|
|
||||||
|
|
||||||
# -- Menu bar colors (id 17+) --
|
# -- Menu bar colors (id 17+) --
|
||||||
C_MENU = 17 # background menubar (sama dengan header)
|
C_MENU = 17 # background menubar (sama dengan header)
|
||||||
@ -50,7 +49,6 @@ def init_colors():
|
|||||||
curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue
|
curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue
|
||||||
curses.init_pair(C_TOOL_CALL, curses.COLOR_YELLOW + 8, -1) # bright yellow
|
curses.init_pair(C_TOOL_CALL, curses.COLOR_YELLOW + 8, -1) # bright yellow
|
||||||
curses.init_pair(C_TOOL_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta
|
curses.init_pair(C_TOOL_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta
|
||||||
curses.init_pair(C_USAGE, 8, -1) # abu-abu di atas bg default
|
|
||||||
|
|
||||||
# Menu bar
|
# Menu bar
|
||||||
curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
||||||
|
|||||||
@ -53,58 +53,32 @@ class SessionStore:
|
|||||||
`messages` state aktif yang dikirim ke LLM (list dict).
|
`messages` state aktif yang dikirim ke LLM (list dict).
|
||||||
`on_append` dipanggil setelah tiap pesan masuk — untuk persistensi
|
`on_append` dipanggil setelah tiap pesan masuk — untuk persistensi
|
||||||
(TUI/GUI/API via NeoSessionManager). Boleh None.
|
(TUI/GUI/API via NeoSessionManager). Boleh None.
|
||||||
`on_truncate` dipanggil dengan jumlah pesan yang harus dihapus dari ekor
|
|
||||||
saat turn dibatalkan. Boleh None.
|
|
||||||
`keep_model_info`
|
`keep_model_info`
|
||||||
False (default): kunci `model_info` dibuang sebelum pesan
|
False (default): kunci `model_info` dibuang sebelum pesan
|
||||||
masuk list, jadi payload ke provider identik dengan sebelum
|
masuk list, jadi payload ke provider identik dengan sebelum
|
||||||
refactor. Interface yang menampilkan info model menyalakannya.
|
refactor. Interface yang menampilkan info model menyalakannya.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, messages, on_append=None, keep_model_info=False,
|
def __init__(self, messages, on_append=None, keep_model_info=False):
|
||||||
on_truncate=None):
|
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
self._on_append = on_append
|
self._on_append = on_append
|
||||||
self.keep_model_info = keep_model_info
|
self.keep_model_info = keep_model_info
|
||||||
self._on_truncate = on_truncate
|
|
||||||
# Berapa pesan yang sudah ditambahkan loop ini pada turn berjalan.
|
|
||||||
self.turn_appends = 0
|
|
||||||
|
|
||||||
def append(self, message):
|
def append(self, message):
|
||||||
if not self.keep_model_info:
|
if not self.keep_model_info:
|
||||||
message.pop('model_info', None)
|
message.pop('model_info', None)
|
||||||
message.pop('usage', None) # metadata display, bukan payload LLM
|
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
self.turn_appends += 1
|
|
||||||
if self._on_append:
|
if self._on_append:
|
||||||
self._on_append(message)
|
self._on_append(message)
|
||||||
|
|
||||||
def discard_turn(self):
|
|
||||||
"""Buang semua pesan yang ditulis loop ini pada turn yang dibatalkan.
|
|
||||||
|
|
||||||
Penting: assistant message dengan `tool_calls` yang hasilnya tidak ikut
|
|
||||||
lengkap membuat konteks jadi tidak valid bagi banyak provider (400 karena
|
|
||||||
tool_call_id menggantung). Yang tersimpan di DB pun ikut dipangkas lewat
|
|
||||||
`on_truncate`, supaya session yang dibuka lagi tidak membawa sisa turn."""
|
|
||||||
n = self.turn_appends
|
|
||||||
if n > 0:
|
|
||||||
del self.messages[len(self.messages) - n:]
|
|
||||||
self.turn_appends = 0
|
|
||||||
if n and self._on_truncate:
|
|
||||||
self._on_truncate(n)
|
|
||||||
|
|
||||||
class AgentResult:
|
class AgentResult:
|
||||||
# Hasil satu kali jalan loop.
|
# Hasil satu kali jalan loop.
|
||||||
def __init__(self, content=None, session_ended=False, model_info=None, warning=None,
|
def __init__(self, content=None, session_ended=False, model_info=None, warning=None, is_multimodal=False):
|
||||||
is_multimodal=False, usage=None, cancelled=False, partial=""):
|
|
||||||
self.content = content # str | None
|
self.content = content # str | None
|
||||||
self.session_ended = session_ended # tool end_session pernah dipanggil
|
self.session_ended = session_ended # tool end_session pernah dipanggil
|
||||||
self.model_info = model_info or {} # model yang benar-benar menjawab
|
self.model_info = model_info or {} # model yang benar-benar menjawab
|
||||||
self.warning = warning # mis. fallback chain model
|
self.warning = warning # mis. fallback chain model
|
||||||
self.is_multimodal = is_multimodal
|
self.is_multimodal = is_multimodal
|
||||||
self.usage = usage # token turn ini: prompt/completion/total (dict | None)
|
|
||||||
self.cancelled = cancelled # turn dihentikan user sebelum selesai
|
|
||||||
self.partial = partial # teks yang sempat dihasilkan sebelum berhenti
|
|
||||||
|
|
||||||
|
|
||||||
class AgentObserver:
|
class AgentObserver:
|
||||||
@ -126,11 +100,8 @@ class AgentObserver:
|
|||||||
def on_step_start(self, step):
|
def on_step_start(self, step):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_response(self, model_info, warning, usage=None):
|
def on_response(self, model_info, warning):
|
||||||
"""llm.chat() sudah kembali, sebelum engine memproses response.
|
"""llm.chat() sudah kembali, sebelum engine memproses response."""
|
||||||
|
|
||||||
`usage` : token pemakaian satu permintaan (prompt/completion/total),
|
|
||||||
None jika provider tidak mengirimnya."""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_step_end(self, step):
|
def on_step_end(self, step):
|
||||||
@ -153,10 +124,6 @@ class AgentObserver:
|
|||||||
def on_max_iterations(self, max_iterations=None):
|
def on_max_iterations(self, max_iterations=None):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_cancelled(self, result):
|
|
||||||
"""Turn dihentikan user sebelum ada jawaban final."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleObserver(AgentObserver):
|
class ConsoleObserver(AgentObserver):
|
||||||
"""Observer default proses headless (service bot): log gorilla ke stdout.
|
"""Observer default proses headless (service bot): log gorilla ke stdout.
|
||||||
@ -191,20 +158,9 @@ class ConsoleObserver(AgentObserver):
|
|||||||
loggorilla.prcss("agent_loop",
|
loggorilla.prcss("agent_loop",
|
||||||
f"Max iterations ({max_iterations}) reached")
|
f"Max iterations ({max_iterations}) reached")
|
||||||
|
|
||||||
def on_cancelled(self, result):
|
|
||||||
loggorilla.prcss("agent_loop", "Turn cancelled by user")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- loop ---
|
# ------------------------------------------------------------------- loop ---
|
||||||
|
|
||||||
def _cancelled(llm_client) -> bool:
|
|
||||||
"""Apakah user meminta turn ini dibatalkan?
|
|
||||||
|
|
||||||
Hanya bertanya ke LLM client (single source of truth state cancel) — engine
|
|
||||||
tidak punya salinan flag sendiri, jadi tidak ada yang bisa tersinkron rusak."""
|
|
||||||
return bool(getattr(llm_client, "cancel_requested", False))
|
|
||||||
|
|
||||||
|
|
||||||
def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
||||||
observer=None, tool_reminder=None, disable_reasoning=None):
|
observer=None, tool_reminder=None, disable_reasoning=None):
|
||||||
"""Jalankan loop LLM <-> tool sampai ada jawaban final atau batas iterasi.
|
"""Jalankan loop LLM <-> tool sampai ada jawaban final atau batas iterasi.
|
||||||
@ -215,16 +171,6 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
|
|
||||||
Return AgentResult. Engine tidak mengklasifikasikan konten sebagai error;
|
Return AgentResult. Engine tidak mengklasifikasikan konten sebagai error;
|
||||||
itu keputusan interface (lihat CursesObserver di interfaces/tui/agent.py).
|
itu keputusan interface (lihat CursesObserver di interfaces/tui/agent.py).
|
||||||
|
|
||||||
Pembatalan (cancel) dicek pada setiap boundary: sebelum panggilan LLM,
|
|
||||||
tepat setelah respons tiba, dan sebelum tiap tool dijalankan. Saat user
|
|
||||||
membatalkan, loop berhenti seketika dan sisa turn dibuang dari konteks lewat
|
|
||||||
`store.discard_turn()`. Interface yang ingin menawarkan "lanjutkan" dapat
|
|
||||||
menaruh kembali teks parsial (`result.partial`) sebagai pesan user.
|
|
||||||
|
|
||||||
State cancel TIDAK direset di sini: flag hanya boleh dibersihkan oleh
|
|
||||||
interface tepat saat pesan user dikirim. Kalau engine yang membersihkannya,
|
|
||||||
cancel yang ditekan sebelum thread ini sempat jalan akan hilang begitu saja.
|
|
||||||
"""
|
"""
|
||||||
if observer is None:
|
if observer is None:
|
||||||
observer = ConsoleObserver()
|
observer = ConsoleObserver()
|
||||||
@ -236,50 +182,9 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
warning = None
|
warning = None
|
||||||
stream_cb = observer.on_stream_chunk if observer.wants_streaming else None
|
stream_cb = observer.on_stream_chunk if observer.wants_streaming else None
|
||||||
|
|
||||||
# Akumulator token satu turn (semua langkah LLM dijumlahkan — tiap
|
|
||||||
# panggilan mengirim ulang konteks sehingga dihitung ulang oleh provider).
|
|
||||||
any_usage = False
|
|
||||||
turn_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
||||||
|
|
||||||
def _accumulate(u):
|
|
||||||
nonlocal any_usage
|
|
||||||
if not isinstance(u, dict):
|
|
||||||
return
|
|
||||||
any_usage = True
|
|
||||||
for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
|
||||||
v = u.get(k)
|
|
||||||
if isinstance(v, (int, float)):
|
|
||||||
turn_usage[k] += int(v)
|
|
||||||
# Ukuran context window saat itu = prompt tokens panggilan terakhir.
|
|
||||||
pt = u.get("prompt_tokens")
|
|
||||||
if isinstance(pt, (int, float)):
|
|
||||||
turn_usage["context_tokens"] = int(pt)
|
|
||||||
|
|
||||||
def _stop(partial=""):
|
|
||||||
"""Akhiri turn karena dibatalkan: buang sisa turn dari konteks, beri
|
|
||||||
tahu observer, kembalikan hasil tanpa jawaban final."""
|
|
||||||
result = AgentResult(
|
|
||||||
content=None,
|
|
||||||
session_ended=session_ended,
|
|
||||||
model_info=model_info,
|
|
||||||
warning=warning,
|
|
||||||
usage=dict(turn_usage) if any_usage else None,
|
|
||||||
cancelled=True,
|
|
||||||
partial=partial,
|
|
||||||
)
|
|
||||||
store.discard_turn()
|
|
||||||
observer.on_cancelled(result)
|
|
||||||
observer.on_run_end()
|
|
||||||
return result
|
|
||||||
|
|
||||||
observer.on_run_start()
|
observer.on_run_start()
|
||||||
|
|
||||||
for step in range(max_iterations):
|
for step in range(max_iterations):
|
||||||
# Cancel bisa datang dari mana saja: dari antrean sebelum thread ini
|
|
||||||
# sempat jalan, atau dari user saat tool masih berjalan.
|
|
||||||
if _cancelled(llm_client):
|
|
||||||
return _stop()
|
|
||||||
|
|
||||||
observer.on_step_start(step)
|
observer.on_step_start(step)
|
||||||
|
|
||||||
response = llm_client.chat(
|
response = llm_client.chat(
|
||||||
@ -294,15 +199,7 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
# auto-switch / rotasi key / fallback tercatat dengan benar.
|
# auto-switch / rotasi key / fallback tercatat dengan benar.
|
||||||
model_info = getattr(response, "model_info", None) or {}
|
model_info = getattr(response, "model_info", None) or {}
|
||||||
warning = getattr(response, "warning", None)
|
warning = getattr(response, "warning", None)
|
||||||
usage = getattr(response, "usage", None)
|
observer.on_response(model_info, warning)
|
||||||
_accumulate(usage)
|
|
||||||
|
|
||||||
# Stream dipotong user: jangan tulis sisa turn ke konteks.
|
|
||||||
if getattr(response, "cancelled", False) or _cancelled(llm_client):
|
|
||||||
partial = response.content if isinstance(response.content, str) else ""
|
|
||||||
return _stop(partial)
|
|
||||||
|
|
||||||
observer.on_response(model_info, warning, usage)
|
|
||||||
|
|
||||||
if response.tool_calls:
|
if response.tool_calls:
|
||||||
amsg = {
|
amsg = {
|
||||||
@ -312,22 +209,12 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
}
|
}
|
||||||
if model_info:
|
if model_info:
|
||||||
amsg['model_info'] = model_info
|
amsg['model_info'] = model_info
|
||||||
if isinstance(usage, dict) and usage:
|
|
||||||
# Usage per langkah (bukan akumulasi turn) — dipakai interface
|
|
||||||
# untuk menampilkan token di blok tool call yang dihasilkannya.
|
|
||||||
# Hanya metadata display: dibuang sebelum jadi payload LLM.
|
|
||||||
amsg['usage'] = dict(usage)
|
|
||||||
store.append(amsg)
|
store.append(amsg)
|
||||||
|
|
||||||
observer.on_tool_calls(response.content or "",
|
observer.on_tool_calls(response.content or "",
|
||||||
[tc['function']['name'] for tc in response.tool_calls])
|
[tc['function']['name'] for tc in response.tool_calls])
|
||||||
|
|
||||||
for tc in response.tool_calls:
|
for tc in response.tool_calls:
|
||||||
# Sebelum eksekusi tool: batalkan tanpa menjalankan efek samping
|
|
||||||
# (tool adalah bagian paling berbahaya untuk disetop tengah —
|
|
||||||
# lebih baik tidak dijalankan sama sekali daripada separuh jalan).
|
|
||||||
if _cancelled(llm_client):
|
|
||||||
return _stop(response.content or "")
|
|
||||||
observer.on_tool_call(tc['function']['name'], tc['function']['arguments'])
|
observer.on_tool_call(tc['function']['name'], tc['function']['arguments'])
|
||||||
if tc['function']['name'] == 'end_session':
|
if tc['function']['name'] == 'end_session':
|
||||||
session_ended = True
|
session_ended = True
|
||||||
@ -347,8 +234,6 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
'tool_call_id': tc['id'],
|
'tool_call_id': tc['id'],
|
||||||
'content': str(result),
|
'content': str(result),
|
||||||
})
|
})
|
||||||
if _cancelled(llm_client):
|
|
||||||
return _stop(response.content or "")
|
|
||||||
observer.on_step_end(step)
|
observer.on_step_end(step)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -358,10 +243,6 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
msg = {'role': 'assistant', 'content': response.content}
|
msg = {'role': 'assistant', 'content': response.content}
|
||||||
if model_info:
|
if model_info:
|
||||||
msg['model_info'] = model_info
|
msg['model_info'] = model_info
|
||||||
if any_usage:
|
|
||||||
# Total token turn ini — ikut dipersist supaya tampil saat
|
|
||||||
# session dibuka kembali.
|
|
||||||
msg['usage'] = dict(turn_usage)
|
|
||||||
store.append(msg)
|
store.append(msg)
|
||||||
|
|
||||||
if is_multimodal:
|
if is_multimodal:
|
||||||
@ -377,7 +258,6 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
model_info=model_info,
|
model_info=model_info,
|
||||||
warning=warning,
|
warning=warning,
|
||||||
is_multimodal=is_multimodal,
|
is_multimodal=is_multimodal,
|
||||||
usage=dict(turn_usage) if any_usage else None,
|
|
||||||
)
|
)
|
||||||
observer.on_final(final)
|
observer.on_final(final)
|
||||||
observer.on_run_end()
|
observer.on_run_end()
|
||||||
|
|||||||
@ -13,6 +13,7 @@ tekan tombol → bicara → stop → teks hasil transkripsi masuk ke form input
|
|||||||
## Konfigurasi (config.yaml, blok `asr:`)
|
## Konfigurasi (config.yaml, blok `asr:`)
|
||||||
asr:
|
asr:
|
||||||
record_dir : "~/.config/hendrik/recordings" # lokasi file rekaman
|
record_dir : "~/.config/hendrik/recordings" # lokasi file rekaman
|
||||||
|
record_key : f9 # f1..f12 / ctrl-<huruf>
|
||||||
record_backend : pulse # pulse | alsa | oss | windows (dshow)
|
record_backend : pulse # pulse | alsa | oss | windows (dshow)
|
||||||
device : default
|
device : default
|
||||||
windows_mic : "" # backend=windows: nama device dshow
|
windows_mic : "" # backend=windows: nama device dshow
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
@ -28,10 +28,12 @@ mindmapDiagram {
|
|||||||
;top to bottom direction
|
;top to bottom direction
|
||||||
|
|
||||||
* root <<done>>
|
* root <<done>>
|
||||||
|
** .env <<done>>
|
||||||
|
** .env.example <<done>>
|
||||||
** .gitignore <<done>>
|
** .gitignore <<done>>
|
||||||
** LICENSE <<done>>
|
|
||||||
** config.default.yaml <<done>>
|
|
||||||
** config.py
|
** config.py
|
||||||
|
** config.yaml <<done>>
|
||||||
|
** config.default.yaml
|
||||||
** hendrik <<done>>
|
** hendrik <<done>>
|
||||||
** hendrik.py <<done>>
|
** hendrik.py <<done>>
|
||||||
** requirements.txt <<done>>
|
** requirements.txt <<done>>
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
@startuml
|
|
||||||
|
|
||||||
hide circle
|
|
||||||
skinparam linetype ortho
|
|
||||||
|
|
||||||
entity users_telegram {
|
|
||||||
id : int <<PK>>
|
|
||||||
first_name : longtext
|
|
||||||
last_name : longtext null
|
|
||||||
username : longtext null
|
|
||||||
phone_number : longtext null
|
|
||||||
last_update : datetime
|
|
||||||
--
|
|
||||||
}
|
|
||||||
|
|
||||||
entity users {
|
|
||||||
id : binary(40) <<PK>>
|
|
||||||
telegram : int <<FK>>
|
|
||||||
--
|
|
||||||
* telegram = users_telegram.id
|
|
||||||
on update cascade
|
|
||||||
on delete cascade
|
|
||||||
}
|
|
||||||
users ||-o| users_telegram
|
|
||||||
|
|
||||||
entity credit_wallet {
|
|
||||||
id : binary(40) <<PK>>
|
|
||||||
owner : binary(40) <<FK>>
|
|
||||||
balance : int
|
|
||||||
--
|
|
||||||
* owner = users.id
|
|
||||||
on update cascade
|
|
||||||
on delete cascade
|
|
||||||
}
|
|
||||||
users ||--|| credit_wallet
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
@ -1,161 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
import threading
|
|
||||||
from lib import gadget, moop
|
from lib import gadget, moop
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
||||||
|
|
||||||
class StreamCancelled(Exception):
|
|
||||||
"""User meminta pembatalan saat stream berjalan.
|
|
||||||
|
|
||||||
`partial` menyimpan teks yang sudah ter-stream supaya tidak hilang.
|
|
||||||
Sengaja bukan turunan _LLMFail: rantai fallback/auto-switch tidak boleh
|
|
||||||
memperlakukan cancel sebagai kegagalan provider lalu mencoba model
|
|
||||||
berikutnya (jumlah percobaan tidak terbatas)."""
|
|
||||||
|
|
||||||
def __init__(self, partial=""):
|
|
||||||
super().__init__("Stream cancelled by user")
|
|
||||||
self.partial = partial
|
|
||||||
|
|
||||||
|
|
||||||
class _CancelState:
|
|
||||||
"""Penghubung thread UI (yang membatalkan) dan thread agen (pembaca SSE).
|
|
||||||
|
|
||||||
Masalah yang dipecahkan: pembacaan stream memblokir di dalam recv() selama
|
|
||||||
provider diam — model masih 'thinking' sebelum token pertama, header belum
|
|
||||||
dikirim, atau koneksi menggantung. Flag cancel saja tidak cukup karena ia
|
|
||||||
hanya diperiksa ketika satu baris penuh tiba, jadi Cancel/Stop baru
|
|
||||||
bereaksi setelah provider mengirim sesuatu (bisa puluhan detik, atau tidak
|
|
||||||
pernah).
|
|
||||||
|
|
||||||
Solusi: objek socket koneksi aktif dicatat begitu TCP/TLS terbentuk; saat
|
|
||||||
cancel diminta, shutdown(SHUT_RDWR) pada objek itu membangunkan recv()
|
|
||||||
seketika di thread mana pun. Yang dipegang adalah objek socket-nya (bukan
|
|
||||||
fd mentah), sehingga balapan antara cancel dan penutupan koneksi tidak
|
|
||||||
pernah mengenai fd yang sudah dipakai ulang oleh koneksi lain.
|
|
||||||
|
|
||||||
Beberapa stream bisa berjalan bersamaan pada satu klien (Telegram/XMPP
|
|
||||||
memproses tiap pesan di thread sendiri dan berbagi satu LLMClient), jadi
|
|
||||||
semua socket aktif dilacak dan dibangunkan sekaligus — persis seperti flag
|
|
||||||
global lama yang dilihat oleh setiap loop."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._socks = []
|
|
||||||
self.requested = False
|
|
||||||
|
|
||||||
def set_socket(self, sock):
|
|
||||||
"""Daftarkan socket stream aktif. Jika cancel sudah diminta sebelum
|
|
||||||
stream sempat terbentuk, bangunkan sekarang juga."""
|
|
||||||
wake = None
|
|
||||||
with self._lock:
|
|
||||||
if self.requested:
|
|
||||||
wake = [sock]
|
|
||||||
else:
|
|
||||||
# Bersihkan entri yang socket-nya sudah ditutup (mis. sisa
|
|
||||||
# stream yang selesai tanpa sempat release) supaya daftar tidak
|
|
||||||
# tumbuh tanpa batas.
|
|
||||||
self._socks = [s for s in self._socks if not self._dead(s)]
|
|
||||||
if sock not in self._socks:
|
|
||||||
self._socks.append(sock)
|
|
||||||
if wake:
|
|
||||||
self._shutdown_all(wake)
|
|
||||||
|
|
||||||
def release(self, sock):
|
|
||||||
"""Panggil ketika pembacaan selesai: setelah ini cancel tidak pernah
|
|
||||||
menyentuh socket ini lagi. Hanya melepas satu socket — stream thread
|
|
||||||
lain pada klien yang sama tidak boleh ikut terputus pendaftarannya."""
|
|
||||||
if sock is None:
|
|
||||||
return
|
|
||||||
with self._lock:
|
|
||||||
if sock in self._socks:
|
|
||||||
self._socks.remove(sock)
|
|
||||||
|
|
||||||
def request(self):
|
|
||||||
with self._lock:
|
|
||||||
self.requested = True
|
|
||||||
socks = self._socks
|
|
||||||
self._socks = []
|
|
||||||
self._shutdown_all(socks)
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
# Daftar socket TIDAK dikosongkan: pada klien yang dipakai bersama
|
|
||||||
# (Telegram/XMPP) bisa ada stream thread lain yang masih berjalan dan
|
|
||||||
# harus tetap bisa dibatalkan. Socket mati tersapu sendiri oleh
|
|
||||||
# set_socket/release.
|
|
||||||
with self._lock:
|
|
||||||
self.requested = False
|
|
||||||
|
|
||||||
def _shutdown_all(self, socks):
|
|
||||||
for s in socks:
|
|
||||||
self._shutdown(s)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dead(sock):
|
|
||||||
"""True jika socket sudah ditutup (fileno() tidak bisa dipakai lagi)."""
|
|
||||||
try:
|
|
||||||
sock.fileno()
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _shutdown(sock):
|
|
||||||
"""Putus koneksi sekarang juga. Aman dipanggil dua kali atau pada
|
|
||||||
socket yang sudah ditutup: efek yang dibutuhkan hanya membangunkan
|
|
||||||
recv() yang terblokir — sekaligus menghentikan generation di sisi
|
|
||||||
provider, jadi token tidak ditagih untuk output yang dibuang."""
|
|
||||||
try:
|
|
||||||
sock.shutdown(socket.SHUT_RDWR)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _CapturingHandler:
|
|
||||||
"""Campuran handler urllib: mendaftarkan socket koneksi ke _CancelState
|
|
||||||
tepat setelah connect(), sehingga cancel bisa membangunkan pembacaan
|
|
||||||
header maupun body yang terblokir."""
|
|
||||||
|
|
||||||
def __init__(self, state, base_class):
|
|
||||||
super().__init__()
|
|
||||||
self._state = state
|
|
||||||
self._base = base_class
|
|
||||||
|
|
||||||
def do_open(self, http_class, req, **http_conn_args):
|
|
||||||
state = self._state
|
|
||||||
captured = []
|
|
||||||
|
|
||||||
class _Conn(http_class):
|
|
||||||
def connect(self):
|
|
||||||
http_class.connect(self)
|
|
||||||
sock = getattr(self, "sock", None)
|
|
||||||
if sock is not None:
|
|
||||||
captured.append(sock)
|
|
||||||
state.set_socket(sock)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return self._base.do_open(self, _Conn, req, **http_conn_args)
|
|
||||||
except BaseException:
|
|
||||||
# do_open menutup socket-nya sendiri saat gagal: lepas pendaftaran
|
|
||||||
# supaya thread cancel tidak menyentuhnya lagi.
|
|
||||||
for sock in captured:
|
|
||||||
state.release(sock)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
class _HTTPHandler(_CapturingHandler, urllib.request.HTTPHandler):
|
|
||||||
def __init__(self, state):
|
|
||||||
_CapturingHandler.__init__(self, state, urllib.request.HTTPHandler)
|
|
||||||
|
|
||||||
|
|
||||||
class _HTTPSHandler(_CapturingHandler, urllib.request.HTTPSHandler):
|
|
||||||
def __init__(self, state):
|
|
||||||
_CapturingHandler.__init__(self, state, urllib.request.HTTPSHandler)
|
|
||||||
|
|
||||||
|
|
||||||
class _LLMFail(Exception):
|
class _LLMFail(Exception):
|
||||||
"""
|
"""
|
||||||
- 'auth' : 401/403 → rotasi key (coba key berikutnya di provider yang sama)
|
- 'auth' : 401/403 → rotasi key (coba key berikutnya di provider yang sama)
|
||||||
@ -173,11 +21,7 @@ class LLMClient:
|
|||||||
self.model = model
|
self.model = model
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.cancel_requested = False
|
||||||
# State cancel + opener yang menangkap socket koneksi (_CancelState).
|
|
||||||
self._cancel = _CancelState()
|
|
||||||
self._opener = urllib.request.build_opener(
|
|
||||||
_HTTPHandler(self._cancel), _HTTPSHandler(self._cancel))
|
|
||||||
|
|
||||||
# Chaining kandidat set model per tipe ('llm', 'imagevision', ...)
|
# Chaining kandidat set model per tipe ('llm', 'imagevision', ...)
|
||||||
self._chains: dict[str, list[dict]] = {}
|
self._chains: dict[str, list[dict]] = {}
|
||||||
@ -185,53 +29,12 @@ class LLMClient:
|
|||||||
self._chain_key: str | None = None
|
self._chain_key: str | None = None
|
||||||
self._chain_index = 0
|
self._chain_index = 0
|
||||||
|
|
||||||
# -------------------------------------------------------- cancellation ---
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cancel_requested(self) -> bool:
|
|
||||||
return self._cancel.requested
|
|
||||||
|
|
||||||
@cancel_requested.setter
|
|
||||||
def cancel_requested(self, value):
|
|
||||||
# Kompatibel dengan pemanggil lama (`llm.cancel_requested = True/False`).
|
|
||||||
if value:
|
|
||||||
self.cancel()
|
|
||||||
else:
|
|
||||||
self.begin_turn()
|
|
||||||
|
|
||||||
def cancel(self):
|
|
||||||
"""Minta inference berhenti. Aman dipanggil dari thread UI mana pun,
|
|
||||||
kapan pun — termasuk saat stream belum terbentuk atau tool sedang
|
|
||||||
berjalan. Membangunkan pembacaan socket yang terblokir seketika."""
|
|
||||||
self._cancel.request()
|
|
||||||
|
|
||||||
def begin_turn(self):
|
|
||||||
"""Bersihkan state cancel pada awal turn baru (dipanggil interface saat
|
|
||||||
user mengirim pesan). Cancel turn sebelumnya tidak boleh merembes ke
|
|
||||||
turn berikutnya."""
|
|
||||||
self._cancel.reset()
|
|
||||||
|
|
||||||
def _cancelled_message(self, partial=""):
|
|
||||||
"""Respons untuk turn yang dibatalkan user.
|
|
||||||
|
|
||||||
Teks parsial dikembalikan apa adanya di `content` dan ditandai lewat
|
|
||||||
atribut `cancelled` — penanda visual ("dibatalkan") adalah urusan
|
|
||||||
interface, bukan bagian dari isi pesan. Rantai model tidak dianggap
|
|
||||||
gagal: cancel adalah perintah user, bukan masalah provider."""
|
|
||||||
result = self.Message({'content': partial or "", 'tool_calls': None})
|
|
||||||
result.cancelled = True
|
|
||||||
return result
|
|
||||||
|
|
||||||
class Message:
|
class Message:
|
||||||
def __init__(self, msg, model_info=None):
|
def __init__(self, msg, model_info=None):
|
||||||
raw_content = msg.get('content', '') # Ambil konten mentah
|
raw_content = msg.get('content', '') # Ambil konten mentah
|
||||||
self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip <thinking> dari content
|
self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip <thinking> dari content
|
||||||
self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls
|
self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls
|
||||||
self.warning = None
|
self.warning = None
|
||||||
# True jika pesan ini adalah hasil turn yang dihentikan user.
|
|
||||||
self.cancelled = False
|
|
||||||
# Pemakaian token dari provider (prompt/completion/total) jika ada.
|
|
||||||
self.usage = None
|
|
||||||
# Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda
|
# Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda
|
||||||
# dengan model awal permintaan karena auto-switch/fallback.
|
# dengan model awal permintaan karena auto-switch/fallback.
|
||||||
self.model_info = model_info or {}
|
self.model_info = model_info or {}
|
||||||
@ -324,14 +127,7 @@ class LLMClient:
|
|||||||
|
|
||||||
def chat(self, messages, tools=None, on_stream_chunk=None, disable_reasoning=False, tool_reminder=None):
|
def chat(self, messages, tools=None, on_stream_chunk=None, disable_reasoning=False, tool_reminder=None):
|
||||||
|
|
||||||
if self._cancel.requested:
|
|
||||||
# Cancel diminta sebelum permintaan sempat berangkat (mis. saat
|
|
||||||
# tool masih berjalan di langkah sebelumnya). Jangan mulai request
|
|
||||||
# baru — kembalikan turn yang ditandai sebagai dibatalkan.
|
|
||||||
return self._cancelled_message("")
|
|
||||||
|
|
||||||
if not self._chains: # kalau bukan chain
|
if not self._chains: # kalau bukan chain
|
||||||
try:
|
|
||||||
result = self._chat_once(
|
result = self._chat_once(
|
||||||
self.base_url, self.model, self.api_key,
|
self.base_url, self.model, self.api_key,
|
||||||
messages,
|
messages,
|
||||||
@ -340,8 +136,6 @@ class LLMClient:
|
|||||||
disable_reasoning=disable_reasoning,
|
disable_reasoning=disable_reasoning,
|
||||||
tool_reminder=tool_reminder,
|
tool_reminder=tool_reminder,
|
||||||
)
|
)
|
||||||
except StreamCancelled as e:
|
|
||||||
return self._cancelled_message(e.partial)
|
|
||||||
result.model_info = self._model_info()
|
result.model_info = self._model_info()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -408,13 +202,6 @@ class LLMClient:
|
|||||||
# permintaan) supaya pemanggil bisa menampilkan data yang jujur.
|
# permintaan) supaya pemanggil bisa menampilkan data yang jujur.
|
||||||
result.model_info = self._model_info(cand)
|
result.model_info = self._model_info(cand)
|
||||||
return result
|
return result
|
||||||
except StreamCancelled as e:
|
|
||||||
# Cancel user: hentikan rantai total — bukan kegagalan provider,
|
|
||||||
# jadi jangan lompat ke kandidat model berikutnya.
|
|
||||||
self._chain_index = idx
|
|
||||||
result = self._cancelled_message(e.partial)
|
|
||||||
result.model_info = self._model_info(cand)
|
|
||||||
return result
|
|
||||||
except _LLMFail as e:
|
except _LLMFail as e:
|
||||||
last = e
|
last = e
|
||||||
if e.kind == "auth" and idx + 1 < len(chain) and self._same_target(chain[idx + 1], cand):
|
if e.kind == "auth" and idx + 1 < len(chain) and self._same_target(chain[idx + 1], cand):
|
||||||
@ -445,36 +232,23 @@ class LLMClient:
|
|||||||
# ------------------------------------------------------- single attempt ---
|
# ------------------------------------------------------- single attempt ---
|
||||||
|
|
||||||
def _chat_once(self, base_url, model, api_key, messages, tools=None,
|
def _chat_once(self, base_url, model, api_key, messages, tools=None,
|
||||||
on_stream_chunk=None, disable_reasoning=False, tool_reminder=None,
|
on_stream_chunk=None, disable_reasoning=False, tool_reminder=None):
|
||||||
include_usage=True):
|
|
||||||
url = f"{base_url.rstrip('/')}/chat/completions"
|
url = f"{base_url.rstrip('/')}/chat/completions"
|
||||||
# Sanitasi: buang metadata display (model_info/usage) dari pesan sebelum
|
|
||||||
# jadi payload — beberapa provider memvalidasi field pesan dengan ketat.
|
|
||||||
msgs_clean = [
|
|
||||||
{k: v for k, v in m.items() if k not in ("model_info", "usage")}
|
|
||||||
if isinstance(m, dict) else m
|
|
||||||
for m in (messages or [])
|
|
||||||
]
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": msgs_clean,
|
"messages": messages,
|
||||||
"stream": True # Enable streaming
|
"stream": True # Enable streaming
|
||||||
}
|
}
|
||||||
if include_usage:
|
|
||||||
# Minta provider menyertakan pemakaian token (input/output) pada
|
|
||||||
# chunk terakhir stream. Provider yang tidak mendukungnya akan
|
|
||||||
# di-retry tanpa parameter ini (lihat penanganan HTTP 400).
|
|
||||||
payload["stream_options"] = {"include_usage": True}
|
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
payload["tool_choice"] = "auto"
|
payload["tool_choice"] = "auto"
|
||||||
if tool_reminder:
|
if tool_reminder:
|
||||||
# Inject tool reminder sebagai system message sebelum user message terakhir
|
# Inject tool reminder sebagai system message sebelum user message terakhir
|
||||||
for i in range(len(msgs_clean) - 1, -1, -1):
|
for i in range(len(messages) - 1, -1, -1):
|
||||||
if msgs_clean[i].get("role") == "user":
|
if messages[i].get("role") == "user":
|
||||||
payload["messages"] = msgs_clean[:i] + [
|
payload["messages"] = messages[:i] + [
|
||||||
{"role": "system", "content": tool_reminder}
|
{"role": "system", "content": tool_reminder}
|
||||||
] + msgs_clean[i:]
|
] + messages[i:]
|
||||||
break
|
break
|
||||||
|
|
||||||
# Hanya kirim parameter reasoning jika diminta eksplisit
|
# Hanya kirim parameter reasoning jika diminta eksplisit
|
||||||
@ -494,20 +268,16 @@ class LLMClient:
|
|||||||
reasoning_content = ""
|
reasoning_content = ""
|
||||||
full_multimodal_content = []
|
full_multimodal_content = []
|
||||||
stream_started = False
|
stream_started = False
|
||||||
usage = None # prompt/completion/total tokens dari chunk terakhir
|
|
||||||
|
|
||||||
my_sock = None
|
|
||||||
try:
|
|
||||||
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
||||||
# Cadangan pendaftaran socket (jika handler capture tidak
|
|
||||||
# sempat mengisi, mis. perubahan internal urllib): cukup untuk
|
|
||||||
# membangunkan pembacaan body.
|
|
||||||
my_sock = self._register_socket(resp)
|
|
||||||
try:
|
try:
|
||||||
|
self.cancel_requested = False
|
||||||
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||||
# Streaming: baca line by line
|
# Streaming: baca line by line
|
||||||
for line in resp:
|
for line in resp:
|
||||||
if self._cancel.requested:
|
if self.cancel_requested:
|
||||||
raise StreamCancelled(full_content)
|
# Stream cancellation
|
||||||
|
full_content += "\n\n[Stream cancelled by user]"
|
||||||
|
break
|
||||||
|
|
||||||
line = line.decode('utf-8').strip()
|
line = line.decode('utf-8').strip()
|
||||||
if not line or not line.startswith('data: '):
|
if not line or not line.startswith('data: '):
|
||||||
@ -522,15 +292,9 @@ class LLMClient:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pemakaian token datang di chunk penutup (choices kosong
|
|
||||||
# pada banyak provider) — ambil selama masih None / terbaru.
|
|
||||||
if chunk.get('usage'):
|
|
||||||
usage = chunk['usage']
|
|
||||||
|
|
||||||
# Parse delta dari chunk
|
# Parse delta dari chunk
|
||||||
choices = chunk.get('choices') or []
|
delta = chunk.get('choices', [{}])[0].get('delta', {})
|
||||||
delta = choices[0].get('delta', {}) if choices else {}
|
finish_reason = chunk.get('choices', [{}])[0].get('finish_reason', None)
|
||||||
finish_reason = choices[0].get('finish_reason', None) if choices else None
|
|
||||||
|
|
||||||
# Stream reasoning content jika ada
|
# Stream reasoning content jika ada
|
||||||
if 'reasoning_content' in delta:
|
if 'reasoning_content' in delta:
|
||||||
@ -580,19 +344,6 @@ class LLMClient:
|
|||||||
stream_started = True
|
stream_started = True
|
||||||
if on_stream_chunk:
|
if on_stream_chunk:
|
||||||
on_stream_chunk(t)
|
on_stream_chunk(t)
|
||||||
finally:
|
|
||||||
# Lepas pendaftaran begitu pembacaan selesai: socket akan
|
|
||||||
# ditutup oleh with-block, dan thread cancel tidak boleh
|
|
||||||
# menyentuhnya lagi. Hanya socket milik panggilan ini —
|
|
||||||
# stream thread lain pada klien yang sama tetap terdaftar.
|
|
||||||
self._cancel.release(my_sock)
|
|
||||||
my_sock = None
|
|
||||||
|
|
||||||
# shutdown() dari thread cancel bisa membuat pembacaan berakhir rapi
|
|
||||||
# seperti EOF — pastikan itu tidak disalahartikan sebagai jawaban
|
|
||||||
# lengkap.
|
|
||||||
if self._cancel.requested:
|
|
||||||
raise StreamCancelled(full_content)
|
|
||||||
|
|
||||||
# Build final response
|
# Build final response
|
||||||
if full_multimodal_content:
|
if full_multimodal_content:
|
||||||
@ -629,11 +380,7 @@ class LLMClient:
|
|||||||
message['tool_calls'] = valid_tool_calls
|
message['tool_calls'] = valid_tool_calls
|
||||||
|
|
||||||
response = {'choices': [{'message': message}]}
|
response = {'choices': [{'message': message}]}
|
||||||
except StreamCancelled:
|
|
||||||
raise
|
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if self._cancel.requested:
|
|
||||||
raise StreamCancelled(full_content)
|
|
||||||
body_text = ""
|
body_text = ""
|
||||||
try:
|
try:
|
||||||
body_text = e.read().decode('utf-8', errors='replace')
|
body_text = e.read().decode('utf-8', errors='replace')
|
||||||
@ -652,35 +399,19 @@ class LLMClient:
|
|||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if include_usage and e.code in (400, 422) and "stream_options" in (body_text or "").lower():
|
|
||||||
# Provider menolak parameter stream_options — ulangi permintaan
|
|
||||||
# tanpanya; token usage tidak tersedia tapi respons tetap jalan.
|
|
||||||
return self._chat_once(
|
|
||||||
base_url, model, api_key, messages, tools=tools,
|
|
||||||
on_stream_chunk=on_stream_chunk,
|
|
||||||
disable_reasoning=disable_reasoning,
|
|
||||||
tool_reminder=tool_reminder,
|
|
||||||
include_usage=False,
|
|
||||||
)
|
|
||||||
detail = f" - {body_text[:500]}" if body_text else ""
|
detail = f" - {body_text[:500]}" if body_text else ""
|
||||||
if e.code in (401, 403):
|
if e.code in (401, 403):
|
||||||
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
|
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
|
||||||
raise _LLMFail("unreachable", f"HTTP {e.code} {e.reason}{detail}")
|
raise _LLMFail("unreachable", f"HTTP {e.code} {e.reason}{detail}")
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
if self._cancel.requested:
|
|
||||||
raise StreamCancelled(full_content)
|
|
||||||
if stream_started:
|
if stream_started:
|
||||||
return self.Message({'content': f"Error: {e.reason}", 'tool_calls': None})
|
return self.Message({'content': f"Error: {e.reason}", 'tool_calls': None})
|
||||||
raise _LLMFail("unreachable", f"Connection error: {e.reason}")
|
raise _LLMFail("unreachable", f"Connection error: {e.reason}")
|
||||||
except (TimeoutError, socket.timeout) as e:
|
except (TimeoutError, socket.timeout) as e:
|
||||||
if self._cancel.requested:
|
|
||||||
raise StreamCancelled(full_content)
|
|
||||||
if stream_started:
|
if stream_started:
|
||||||
return self.Message({'content': f"Error: Timeout", 'tool_calls': None})
|
return self.Message({'content': f"Error: Timeout", 'tool_calls': None})
|
||||||
raise _LLMFail("unreachable", f"Timeout")
|
raise _LLMFail("unreachable", f"Timeout")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self._cancel.requested:
|
|
||||||
raise StreamCancelled(full_content)
|
|
||||||
if stream_started:
|
if stream_started:
|
||||||
return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None})
|
return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None})
|
||||||
raise _LLMFail("unreachable", f"Error: {str(e)}")
|
raise _LLMFail("unreachable", f"Error: {str(e)}")
|
||||||
@ -717,28 +448,4 @@ class LLMClient:
|
|||||||
message.pop('reasoning_content', None)
|
message.pop('reasoning_content', None)
|
||||||
message.pop('reasoning', None)
|
message.pop('reasoning', None)
|
||||||
|
|
||||||
result = self.Message(message)
|
return self.Message(message)
|
||||||
result.usage = usage
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _socket_of(resp):
|
|
||||||
"""Objek socket di balik HTTPResponse; None jika struktur urllib
|
|
||||||
berbeda dari yang diharapkan (maka fallback wake tidak tersedia)."""
|
|
||||||
try:
|
|
||||||
fp = resp.fp
|
|
||||||
raw = getattr(fp, "raw", None)
|
|
||||||
sock = getattr(raw, "_sock", None)
|
|
||||||
return sock if isinstance(sock, socket.socket) else None
|
|
||||||
except (AttributeError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _register_socket(self, resp):
|
|
||||||
"""Daftarkan socket respons ke state cancel; kembalikan socket-nya
|
|
||||||
supaya pemanggil bisa melepasnya sendiri saat selesai (tidak menyentuh
|
|
||||||
stream thread lain). Jika cancel sudah diminta, set_socket langsung
|
|
||||||
memutusnya sehingga pembacaan pertama tidak sempat terblokir."""
|
|
||||||
sock = self._socket_of(resp)
|
|
||||||
if sock is not None:
|
|
||||||
self._cancel.set_socket(sock)
|
|
||||||
return sock
|
|
||||||
|
|||||||
@ -136,26 +136,6 @@ class NeoSessionManager:
|
|||||||
def update_model_info(self, doc_id: int, model_info: dict) -> bool:
|
def update_model_info(self, doc_id: int, model_info: dict) -> bool:
|
||||||
return self._table.update({"model_info": model_info}, doc_ids=[doc_id])
|
return self._table.update({"model_info": model_info}, doc_ids=[doc_id])
|
||||||
|
|
||||||
def remove_last_messages(self, doc_id: int, count: int) -> bool:
|
|
||||||
"""Hapus `count` pesan terakhir dari session.
|
|
||||||
|
|
||||||
Dipakai saat turn dibatalkan: engine sudah membuang sisa turn dari
|
|
||||||
konteks aktif, dan DB harus ikut dipangkas supaya session yang dibuka
|
|
||||||
lagi tidak menampilkan — atau mengirim — pesan assistant dengan
|
|
||||||
tool_calls yang hasilnya tidak ada."""
|
|
||||||
if count <= 0:
|
|
||||||
return True
|
|
||||||
doc = self._table.get(doc_id=doc_id)
|
|
||||||
if doc is None:
|
|
||||||
return False
|
|
||||||
messages = doc["messages"]
|
|
||||||
del messages[len(messages) - min(count, len(messages)):]
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
return self._table.update(
|
|
||||||
{"messages": messages, "updated_at": now},
|
|
||||||
doc_ids=[doc_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _doc_to_session(doc) -> NeoSession:
|
def _doc_to_session(doc) -> NeoSession:
|
||||||
return NeoSession(
|
return NeoSession(
|
||||||
|
|||||||
@ -259,11 +259,6 @@ class TelegramClient:
|
|||||||
|
|
||||||
tool_reminder = None
|
tool_reminder = None
|
||||||
|
|
||||||
# Turn baru: bersihkan state cancel. LLMClient dipakai bersama antar
|
|
||||||
# interface, jadi cancel yang tertinggal (mis. dari TUI) tidak boleh
|
|
||||||
# membuat turn ini langsung berhenti.
|
|
||||||
self._llm.begin_turn()
|
|
||||||
|
|
||||||
outcome = run_agent_loop(
|
outcome = run_agent_loop(
|
||||||
SessionStore(session.messages),
|
SessionStore(session.messages),
|
||||||
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
||||||
@ -272,15 +267,6 @@ class TelegramClient:
|
|||||||
)
|
)
|
||||||
final_content, should_close = outcome.content, outcome.session_ended
|
final_content, should_close = outcome.content, outcome.session_ended
|
||||||
|
|
||||||
if outcome.cancelled:
|
|
||||||
# Turn dihentikan (cancel adalah flag global pada LLMClient yang
|
|
||||||
# dipakai bersama antar interface). Sisa turn sudah dibuang engine
|
|
||||||
# dari konteks; jangan kirim pesan "max iterations" yang menyesatkan.
|
|
||||||
loggorilla.prcss('telegram', f'Turn cancelled for {chat_id}')
|
|
||||||
session.start_timer(86400 if chat_type == 'private' else 1800,
|
|
||||||
self._timeout_session, chat_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
def _pop_ghost(sentinel: str):
|
def _pop_ghost(sentinel: str):
|
||||||
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang
|
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang
|
||||||
# tidak dikirim agar konteks tidak tercemar balasan hantu.
|
# tidak dikirim agar konteks tidak tercemar balasan hantu.
|
||||||
|
|||||||
@ -624,10 +624,6 @@ class XMPPClient(ClientXMPP):
|
|||||||
|
|
||||||
tool_reminder = None
|
tool_reminder = None
|
||||||
|
|
||||||
# Turn baru: bersihkan state cancel warisan antar interface/thread
|
|
||||||
# yang berbagi satu LLMClient.
|
|
||||||
self._llm.begin_turn()
|
|
||||||
|
|
||||||
outcome = run_agent_loop(
|
outcome = run_agent_loop(
|
||||||
SessionStore(session.messages),
|
SessionStore(session.messages),
|
||||||
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
||||||
@ -636,13 +632,6 @@ class XMPPClient(ClientXMPP):
|
|||||||
)
|
)
|
||||||
final_content, should_close = outcome.content, outcome.session_ended
|
final_content, should_close = outcome.content, outcome.session_ended
|
||||||
|
|
||||||
if outcome.cancelled:
|
|
||||||
# Turn dihentikan (flag cancel global pada LLMClient bersama).
|
|
||||||
# Sisa turn sudah dibuang engine; jangan kirim "max iterations".
|
|
||||||
print(f'[{_ts()}] DM: turn cancelled for {jid}', flush=True)
|
|
||||||
session.start_timer(86400, self._timeout_session, jid, 'chat')
|
|
||||||
return
|
|
||||||
|
|
||||||
if final_content is not None:
|
if final_content is not None:
|
||||||
if is_roleplay:
|
if is_roleplay:
|
||||||
# For DM: No selective response here.
|
# For DM: No selective response here.
|
||||||
@ -723,9 +712,6 @@ class XMPPClient(ClientXMPP):
|
|||||||
|
|
||||||
tool_reminder = None
|
tool_reminder = None
|
||||||
|
|
||||||
# Turn baru: bersihkan state cancel warisan antar interface/thread.
|
|
||||||
self._llm.begin_turn()
|
|
||||||
|
|
||||||
outcome = run_agent_loop(
|
outcome = run_agent_loop(
|
||||||
SessionStore(session.messages),
|
SessionStore(session.messages),
|
||||||
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
||||||
@ -734,13 +720,6 @@ class XMPPClient(ClientXMPP):
|
|||||||
)
|
)
|
||||||
final_content, should_close = outcome.content, outcome.session_ended
|
final_content, should_close = outcome.content, outcome.session_ended
|
||||||
|
|
||||||
if outcome.cancelled:
|
|
||||||
# Turn dihentikan: sisa turn sudah dibuang engine dari konteks.
|
|
||||||
# Diam total (tanpa "max iterations") lalu lanjutkan timer sesi.
|
|
||||||
print(f'[{_ts()}] MUC: turn cancelled for {room}', flush=True)
|
|
||||||
session.start_timer(1800, self._timeout_session, room, 'groupchat')
|
|
||||||
return
|
|
||||||
|
|
||||||
def _pop_ghost(sentinel: str):
|
def _pop_ghost(sentinel: str):
|
||||||
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang
|
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang
|
||||||
# tidak dikirim agar konteks tidak tercemar balasan hantu.
|
# tidak dikirim agar konteks tidak tercemar balasan hantu.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user