Compare commits
12 Commits
character/
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d3b48c4c2 | |||
| 9dfd395d93 | |||
| 2584600dbf | |||
| f9ba636837 | |||
| d63aa0aa4e | |||
|
|
3a50722f4c | ||
|
|
a516bfe31a | ||
| 261c24dc1a | |||
| 659f52ee9f | |||
|
|
98080ca83c | ||
|
|
d3d16e85c7 | ||
|
|
911e44fdd5 |
1
agent/characters/grace/.gitignore
vendored
@ -1 +0,0 @@
|
||||
service.yaml
|
||||
@ -1,6 +0,0 @@
|
||||
codename: "Grace"
|
||||
name: "Grace"
|
||||
skill:
|
||||
- "roleplayer"
|
||||
verbosity: "balanced"
|
||||
disable_reasoning: true
|
||||
@ -1,42 +0,0 @@
|
||||
# Grace
|
||||
|
||||
Grace is a sophisticated, patient, and encouraging language mentor who specializes in teaching English through the art of roleplaying.
|
||||
|
||||
**Roleplay Identity:** Grace is the user's schoolmate and next-door neighbor. The setting is centered around school life and typical activities for their age (studying together, hanging out, school events, etc.), providing a relatable and dynamic environment for learning.
|
||||
|
||||
She doesn't just teach grammar; she creates immersive scenarios where the user is forced to use English in a natural, contextual way.
|
||||
|
||||
## Core Objective
|
||||
The primary goal is to help the user improve their English proficiency (grammar, vocabulary, and fluency) by engaging in immersive school-life roleplay. Grace acts as both a friend and a subtle mentor.
|
||||
|
||||
## Appearance
|
||||
- Female, 17 years old.
|
||||
- Height: 165cm, slender with an upright and elegant posture.
|
||||
- Long, neatly flowing blonde hair and clear blue eyes, embodying a classic European look.
|
||||
- Style: Neat school uniform or simple yet chic casual wear.
|
||||
|
||||
## Operational Guidelines
|
||||
|
||||
### 1. Language Policy
|
||||
- **Primary Language:** English. Grace speaks English by default.
|
||||
- **Multilingual Understanding & Tolerance:** She understands other languages. While she encourages the use of English, she is tolerant and will allow the user to use their native language if they are truly struggling or cannot find the right words. She will then provide the English translation and encourage the user to repeat it.
|
||||
- **Guidance:** When the user makes a mistake or uses awkward phrasing, Grace will gently guide them towards the "correct" or "more natural" way of saying it.
|
||||
|
||||
### 2. The Roleplay-Learning Cycle
|
||||
- **Scenario Initiation:** Grace focuses on school-related and teenage activities (e.g., walking to school together, discussing homework, library sessions, or school festivals).
|
||||
- **Immersion:** She stays in character throughout the interaction.
|
||||
- **Natural Correction Mechanism:**
|
||||
- Corrections should be integrated naturally into the conversation, as a friend would do, without using OOC tags or separate blocks.
|
||||
- Example:
|
||||
*User: "I goes to school yesterday."*
|
||||
*Grace: "Oh, you went to school yesterday? I thought you were sick! By the way, remember to use 'went' for things that happened in the past. Anyway, did you finish the math homework?"*
|
||||
|
||||
### 3. Pedagogical Approach
|
||||
- **Scaffolding:** Start with simpler language if the user is a beginner and gradually increase complexity.
|
||||
- **Vocabulary Expansion:** Introduce one or two new useful idioms or advanced words per session and encourage the user to use them in the roleplay.
|
||||
- **Positive Reinforcement:** Praise the user when they use a complex structure correctly or improve upon a previous mistake.
|
||||
|
||||
## Tone and Style
|
||||
- **Elegant and Polished:** Her English is impeccable and serves as a gold standard for the user.
|
||||
- **Supportive:** Never condescending. She views mistakes as stepping stones to fluency.
|
||||
- **Creative:** Able to pivot the story dynamically based on user input.
|
||||
@ -1,23 +0,0 @@
|
||||
# This file serves as a template for character-specific service overrides.
|
||||
# Copy this to agent/characters/<character>/service.yaml and fill in the credentials.
|
||||
|
||||
xmpp:
|
||||
enabled: false
|
||||
username: ""
|
||||
password: ""
|
||||
muc_rooms: # YAML list, e.g.:
|
||||
# - "room1@conference.server"
|
||||
# - "room2@conference.server"
|
||||
nickname: "" # custom MUC nickname (empty = use username)
|
||||
selective_response: true # true = only response if mentioned/relevant
|
||||
debug: false # true = tampilkan log debug menyeluruh (semua stanza XML, lifecycle, dll)
|
||||
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
|
||||
|
||||
telegram:
|
||||
enabled: false
|
||||
token: ""
|
||||
allowed_group_ids: # YAML list, empty = all group. e.g.:
|
||||
# - "123456789"
|
||||
# - "987654321"
|
||||
selective_response: true # true = only response if mentioned/relevant
|
||||
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
agent:
|
||||
character : hendrik # Directory name in agent/characters/<character>/
|
||||
max_iterations : 40 # step(s)
|
||||
max_iterations : 80 # step(s)
|
||||
max_history_chat : 0 # message(s) - 0 for unlimited (Dead Config)
|
||||
max_tool_output : 0 # character(s) - 0 for unlimited (Connected to AGENT_MAX_TOOL_OUTPUT but Dead Config)
|
||||
max_tool_output : 0 # character(s) - 0 for unlimited
|
||||
|
||||
user:
|
||||
id: # Fill your user UUID here, empty = TUI/MUC guest
|
||||
@ -15,24 +15,23 @@ llm:
|
||||
moop: # Model Option
|
||||
db_path: "~/.config/hendrik/moop.sqlite3"
|
||||
|
||||
asr: # Speech-to-Text (push-to-talk TUI). Model dikelola via moop tipe 'asr'.
|
||||
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
|
||||
device : default # nama device input
|
||||
windows_mic : "" # backend=windows: nama device dshow,
|
||||
# contoh "Microphone Array (Realtek(R) Audio)"
|
||||
format : wav # wav | ogg (opus)
|
||||
sample_rate : 16000 # Hz
|
||||
channels : 1
|
||||
max_seconds : 60 # auto-stop pengaman
|
||||
transcribe_timeout: 60 # detik timeout request ASR
|
||||
keep_recordings : true # false = hapus file setelah ditranskrip
|
||||
asr:
|
||||
record_dir : "~/.config/hendrik/recordings"
|
||||
record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows
|
||||
device : default
|
||||
windows_mic : "" # if backend==windows: device dshow name, example: "Microphone Array (Realtek(R) Audio)"
|
||||
format : wav # wav | ogg (opus)
|
||||
sample_rate : 16000 # Hz
|
||||
channels : 1
|
||||
max_seconds : 120 # second
|
||||
transcribe_timeout : 60 # second
|
||||
keep_recordings : false
|
||||
auto_submit : true
|
||||
|
||||
session: # Chat session
|
||||
session:
|
||||
db_path: "~/.config/hendrik/sessions.json"
|
||||
|
||||
ragroleplay: # Use nomic-embed-text
|
||||
ragroleplay:
|
||||
db_path : "~/.config/hendrik/ragroleplay"
|
||||
vector_size : 768 # used on table create only
|
||||
|
||||
@ -51,7 +50,7 @@ xmpp:
|
||||
# - "room2@conference.server"
|
||||
nickname: "" # custom MUC nickname (empty = use username)
|
||||
selective_response: true # true = only response if mentioned/relevant
|
||||
debug: false # true = tampilkan log debug menyeluruh (semua stanza XML, lifecycle, dll)
|
||||
debug: false # true = display debug log (stanza XML, lifecycle, etc)
|
||||
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
|
||||
|
||||
telegram:
|
||||
|
||||
@ -58,8 +58,12 @@ ASR_MAX_SECONDS = int(_yaml_get("asr", "max_seconds", 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)
|
||||
|
||||
# Tombol push-to-talk: "f1".."f12" atau "ctrl-<huruf>". Default F9.
|
||||
ASR_RECORD_KEY = str(_yaml_get("asr", "record_key", default="f9")).strip().lower()
|
||||
# Tombol push-to-talk dipatok tetap ke F9 (tidak bisa dikonfigurasi).
|
||||
# Lihat interfaces/tui/actions.py -> ACTIONS["push_to_talk"].
|
||||
|
||||
# true = hasil transkripsi langsung dikirim sebagai pesan,
|
||||
# false = hasil hanya disisipkan ke form input (user kirim sendiri via Ctrl+Enter).
|
||||
ASR_AUTO_SUBMIT = _as_bool(_yaml_get("asr", "auto_submit", default=False), default=False)
|
||||
|
||||
|
||||
XMPP_USERNAME = _yaml_get("xmpp", "username", default="")
|
||||
|
||||
2
hendrik
@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# wrapper to run the TUI agent from anywhere
|
||||
# wrapper to run the hendrik agent from anywhere
|
||||
|
||||
# Set HENDRIK_DIR env var to override, or update the default below
|
||||
DEFAULT_DIR="/opt/hendrik"
|
||||
|
||||
28
hendrik.py
@ -14,23 +14,25 @@ from interfaces.tui import HendrikTUI
|
||||
|
||||
tools_definition = [
|
||||
# Coder Tools
|
||||
gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
|
||||
gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ),
|
||||
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
|
||||
gadget.tools_mapping( schema = coder.schema_search_grep, handler = coder.search_grep ),
|
||||
gadget.tools_mapping( schema = coder.schema_search_glob, handler = coder.search_glob ),
|
||||
gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
|
||||
gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ),
|
||||
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
|
||||
gadget.tools_mapping( schema = coder.schema_search_grep, handler = coder.search_grep ),
|
||||
gadget.tools_mapping( schema = coder.schema_search_glob, handler = coder.search_glob ),
|
||||
# Carrack Tools
|
||||
gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ),
|
||||
gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ),
|
||||
# Vision Tools
|
||||
gadget.tools_mapping( schema = vision.schema_describe_image, handler = vision.describe_image ),
|
||||
gadget.tools_mapping( schema = vision.schema_describe_image, handler = vision.describe_image ),
|
||||
# Image Generation Tools
|
||||
gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ),
|
||||
gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ),
|
||||
# Instruction Tools
|
||||
gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet
|
||||
gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay
|
||||
gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet (global)
|
||||
gadget.tools_mapping( schema = smart.schema_instruction_char_use, handler = smart.instruction_char_use ), # snippet (private per character)
|
||||
gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay (global)
|
||||
gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_char_use, handler = smart_rp.rp_instruction_char_use ), # roleplay (private per character)
|
||||
# Rag Roleplay Tools
|
||||
gadget.tools_mapping( schema = ragroleplay_tools.schema_users_store, handler = ragroleplay_tools.users_store ),
|
||||
gadget.tools_mapping( schema = ragroleplay_tools.schema_user_load, handler = ragroleplay_tools.user_load ), # s_load
|
||||
|
||||
324
instruction/erd/erd-plantuml-create.md
Normal file
@ -0,0 +1,324 @@
|
||||
# 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
|
||||
6
instruction/erd/erd.md
Normal file
@ -0,0 +1,6 @@
|
||||
# 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,6 +16,7 @@ from .keycodes import (
|
||||
KEY_F2,
|
||||
KEY_F4,
|
||||
KEY_F6,
|
||||
KEY_F9,
|
||||
)
|
||||
from .input import (
|
||||
new_session_popup,
|
||||
@ -29,11 +30,8 @@ from .moopui import (
|
||||
providers_manage_popup,
|
||||
)
|
||||
from .agent import submit, log
|
||||
from .keycodes import resolve_key
|
||||
from . import talk as talk_mod
|
||||
|
||||
import config as _config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- handlers ---
|
||||
def _exit_app(app, stdscr):
|
||||
@ -41,9 +39,13 @@ def _exit_app(app, stdscr):
|
||||
|
||||
|
||||
def _cancel_inference(app, stdscr):
|
||||
app.llm.cancel_requested = True
|
||||
app.agent_done.set()
|
||||
log(app, "system", " Inference cancelled by user")
|
||||
# Hanya menandai + membangunkan stream; penghentian loop dan pembersihan
|
||||
# log dilakukan oleh agent thread sendiri (CursesObserver.on_cancelled),
|
||||
# supaya tidak ada balapan tulis terhadap app.log dari dua thread.
|
||||
# `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):
|
||||
@ -55,8 +57,8 @@ def _record_talk(app, stdscr):
|
||||
|
||||
|
||||
def _talk_configured(app):
|
||||
# Tombol rekam valid (di-resolve dari config). Ketersediaan chain 'asr'
|
||||
# divalidasi di handler supaya bisa memberi pesan panduan, bukan diam.
|
||||
# Tombol rekam dipatok tetap (F9) sehingga selalu valid. Ketersediaan
|
||||
# chain 'asr' divalidasi di handler supaya bisa memberi pesan panduan.
|
||||
return getattr(app, "talk_key", None) is not None
|
||||
|
||||
|
||||
@ -118,12 +120,12 @@ ACTIONS = {
|
||||
"label": "Change Workspace", "mnemonic": "C", "shortcut": "F6",
|
||||
"handler": workspace_popup, "enabled": _ready,
|
||||
},
|
||||
# Push-to-Talk (ASR): tombol berasal dari config.asr.record_key (default F9).
|
||||
# Push-to-Talk (ASR): tombol dipatok tetap F9 (tidak dikonfigurasi).
|
||||
# Handler talk_toggle memvalidasi chain moop 'asr' + ketersediaan mic.
|
||||
"push_to_talk": {
|
||||
"key": resolve_key(getattr(_config, "ASR_RECORD_KEY", "f9")),
|
||||
"key": KEY_F9,
|
||||
"label": "Push-to-Talk", "mnemonic": "T",
|
||||
"shortcut": (getattr(_config, "ASR_RECORD_KEY", "f9") or "").upper(),
|
||||
"shortcut": "F9",
|
||||
"handler": _record_talk,
|
||||
"enabled": lambda app: _ready(app) and _talk_configured(app),
|
||||
},
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime
|
||||
import config
|
||||
from lib import moop
|
||||
|
||||
from lib import ntro, agent_loop
|
||||
from tools.vision import ImagePayload
|
||||
|
||||
|
||||
def _add_msg(app, role, content, **kwargs):
|
||||
@ -16,6 +14,27 @@ def _add_msg(app, role, content, **kwargs):
|
||||
app.current_session.doc_id, role, content, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def _persist_msg(app, message):
|
||||
"""Persist satu pesan yang baru ditulis engine ke store TUI.
|
||||
|
||||
Dipasang sebagai SessionStore.on_append: engine menulis ke app.messages,
|
||||
kita hanya menyalinnya ke session DB (TinyDB)."""
|
||||
if not app.current_session:
|
||||
return
|
||||
extra = {k: v for k, v in message.items() if k not in ("role", "content")}
|
||||
app.session_mgr.add_message(
|
||||
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 = """
|
||||
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
|
||||
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
|
||||
@ -45,12 +64,235 @@ WELCOME_ART = """
|
||||
╚══════════════════════════════════════════╝
|
||||
"""
|
||||
|
||||
def log(app, role, text):
|
||||
app.log.append({
|
||||
def log(app, role, text, model_info=None, usage=None):
|
||||
entry = {
|
||||
"role": role,
|
||||
"text": text,
|
||||
"time": datetime.now().strftime("%H:%M"),
|
||||
})
|
||||
}
|
||||
if model_info:
|
||||
# Display pair: (provider, model)
|
||||
entry["model_info"] = model_info
|
||||
if usage:
|
||||
# Token pemakaian: prompt/completion/total dari provider
|
||||
entry["usage"] = usage
|
||||
app.log.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _pair(model_info):
|
||||
"""(provider, model) untuk display; None jika info tidak ada."""
|
||||
if not model_info:
|
||||
return None
|
||||
return (model_info.get("provider"), model_info.get("model"))
|
||||
|
||||
|
||||
def _tag_ai_logs(app, prev_entries, pair, usage=None):
|
||||
"""Tempelkan info model (dan token usage) yang benar-benar dipakai ke entri
|
||||
log AI yang baru dibuat pada langkah ini.
|
||||
|
||||
Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah
|
||||
llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries`
|
||||
menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python.
|
||||
"""
|
||||
if not pair and not usage:
|
||||
return
|
||||
seen = {id(e) for e in prev_entries}
|
||||
for entry in app.log:
|
||||
if entry.get("role") == "ai" and id(entry) not in seen:
|
||||
if pair:
|
||||
entry["model_info"] = pair
|
||||
if usage:
|
||||
entry["usage"] = usage
|
||||
|
||||
|
||||
class CursesObserver(agent_loop.AgentObserver):
|
||||
"""Terjemahkan hook engine ke state tampilan TUI (app.log, scroll, ntro).
|
||||
|
||||
Semua yang tadinya hardcoded di dalam _agent_loop pindah ke sini, sehingga
|
||||
lib.agent_loop tidak lagi mengenal curses. Interface lain cukup membuat
|
||||
observer sendiri tanpa menyentuh loop.
|
||||
"""
|
||||
|
||||
wants_streaming = True
|
||||
|
||||
# Konten final dengan awalan ini datang dari LLMClient sebagai kegagalan,
|
||||
# bukan jawaban normal — tampilkan sebagai error, bukan balasan AI.
|
||||
_ERROR_PREFIXES = ("Error:", "HTTP Error:")
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.pair = None
|
||||
self.usage = None # token pemakaian langkah LLM terakhir
|
||||
self._buf = []
|
||||
self._placeholder = None
|
||||
self._stamp = 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 ---
|
||||
def on_run_start(self):
|
||||
self._stamp = ntro.start()
|
||||
|
||||
def on_run_end(self):
|
||||
ntro.end(self._stamp)
|
||||
self.app.agent_done.set()
|
||||
|
||||
def on_step_start(self, step):
|
||||
self._stamp_step = ntro.start()
|
||||
self._buf = []
|
||||
self._placeholder = None
|
||||
self.usage = None
|
||||
self._tool_total = 0
|
||||
self._tool_seen = 0
|
||||
log(self.app, "system", f" step {step + 1} \u2014 Thinking...")
|
||||
# Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut
|
||||
# terjaga dan id()-nya tidak dipakai ulang saat entri itu di-pop.
|
||||
self._log_prev = list(self.app.log)
|
||||
self._follow()
|
||||
|
||||
def on_step_end(self, step):
|
||||
ntro.end(self._stamp_step)
|
||||
|
||||
def on_response(self, model_info, warning, usage=None):
|
||||
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()
|
||||
if warning:
|
||||
log(self.app, "system", f" {warning}")
|
||||
|
||||
# --------------------------------------------------------------- stream ---
|
||||
def on_stream_chunk(self, chunk):
|
||||
if self._placeholder is None:
|
||||
# Placeholder dibuat di chunk pertama; isinya diperbarui real-time.
|
||||
self._placeholder = log(self.app, "ai", "")
|
||||
self._buf.append(chunk)
|
||||
self._placeholder["text"] = "".join(self._buf)
|
||||
self._follow()
|
||||
|
||||
# ----------------------------------------------------------------- tools ---
|
||||
def on_tool_calls(self, content, names):
|
||||
# Narasi assistant sebelum tools; placeholder streaming dibuang karena
|
||||
# konten yang sama akan di-log utuh di sini.
|
||||
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():
|
||||
log(self.app, "ai", content, model_info=self.pair, usage=self.usage)
|
||||
_tag_ai_logs(self.app, self._log_prev, self.pair)
|
||||
|
||||
def on_tool_call(self, name, arguments):
|
||||
entry = log(self.app, "tool_call", json.dumps({
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
}), model_info=self.pair)
|
||||
self._tool_seen += 1
|
||||
if self.usage and self._tool_seen >= self._tool_total:
|
||||
entry["usage"] = self.usage
|
||||
self._follow()
|
||||
|
||||
# ---------------------------------------------------------------- final ---
|
||||
def on_final(self, result):
|
||||
content = result.content
|
||||
if isinstance(content, str) and content.startswith(self._ERROR_PREFIXES):
|
||||
self._drop_placeholder()
|
||||
log(self.app, "error", content)
|
||||
log(self.app, "sep", "")
|
||||
return
|
||||
|
||||
if result.is_multimodal:
|
||||
entry = log(self.app, "ai", "[Image generated]",
|
||||
model_info=self.pair, usage=result.usage)
|
||||
elif content is None:
|
||||
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
|
||||
entry = None
|
||||
elif self._placeholder is None and content:
|
||||
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
|
||||
# delta) — pastikan teks tetap terlihat.
|
||||
entry = log(self.app, "ai", content,
|
||||
model_info=self.pair, usage=result.usage)
|
||||
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)
|
||||
log(self.app, "sep", "")
|
||||
|
||||
def on_max_iterations(self, max_iterations=None):
|
||||
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 ---
|
||||
def _follow(self):
|
||||
if self.app.scroll_follow:
|
||||
self.app.scroll = 999999
|
||||
|
||||
def _pop_thinking(self):
|
||||
for i in range(len(self.app.log) - 1, -1, -1):
|
||||
if self.app.log[i].get("role") == "system" and "Thinking" in self.app.log[i].get("text", ""):
|
||||
self.app.log.pop(i)
|
||||
break
|
||||
|
||||
def _drop_placeholder(self):
|
||||
if self._placeholder is None:
|
||||
return
|
||||
entry, self._placeholder = self._placeholder, None
|
||||
for i in range(len(self.app.log) - 1, -1, -1):
|
||||
if self.app.log[i] is entry:
|
||||
self.app.log.pop(i)
|
||||
break
|
||||
|
||||
|
||||
def _run_agent(app):
|
||||
"""Entry point thread agent: satu-satunya tempat TUI memanggil engine."""
|
||||
store = agent_loop.SessionStore(
|
||||
app.messages,
|
||||
on_append=lambda msg: _persist_msg(app, msg),
|
||||
keep_model_info=True,
|
||||
on_truncate=lambda n: _truncate_msg(app, n),
|
||||
)
|
||||
try:
|
||||
agent_loop.run_agent_loop(
|
||||
store,
|
||||
app.llm,
|
||||
app.TOOLS,
|
||||
app.TOOL_HANDLERS,
|
||||
app.agent_max_iterations,
|
||||
observer=CursesObserver(app),
|
||||
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):
|
||||
@ -59,12 +301,6 @@ def submit(app, stdscr):
|
||||
return
|
||||
|
||||
log(app, "user", query)
|
||||
model_info = (
|
||||
moop.resolve_provider(None, app.llm.base_url, app.llm.model),
|
||||
app.llm.model
|
||||
)
|
||||
if app.log:
|
||||
app.log[-1]["model_info"] = model_info
|
||||
app.input_buffer = [""]
|
||||
app.input_line = 0
|
||||
app.input_col = 0
|
||||
@ -83,142 +319,15 @@ def submit(app, stdscr):
|
||||
app._model_info(),
|
||||
)
|
||||
|
||||
_add_msg(app, "user", query, model_info=app._model_info())
|
||||
_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_thread = threading.Thread(
|
||||
target=_agent_loop,
|
||||
target=_run_agent,
|
||||
args=(app,),
|
||||
daemon=True,
|
||||
)
|
||||
app.agent_thread.start()
|
||||
|
||||
|
||||
def _agent_loop(app):
|
||||
stamp = ntro.start()
|
||||
|
||||
for step in range(app.agent_max_iterations):
|
||||
stamp_step = ntro.start()
|
||||
log(app, "system", f" step {step + 1} \u2014 Thinking...")
|
||||
if app.scroll_follow:
|
||||
app.scroll = 999999
|
||||
|
||||
# Streaming response
|
||||
stream_buffer = []
|
||||
placeholder_marker = None
|
||||
|
||||
def on_stream_chunk(chunk):
|
||||
nonlocal placeholder_marker
|
||||
# Buat placeholder di chunk pertama saja
|
||||
if placeholder_marker is None:
|
||||
placeholder_marker = f"_stream_placeholder_{step}"
|
||||
log(app, "ai", placeholder_marker)
|
||||
stream_buffer.append(chunk)
|
||||
current_text = ''.join(stream_buffer)
|
||||
# Update placeholder secara real-time
|
||||
for i in range(len(app.log) - 1, -1, -1):
|
||||
# Cari placeholder berdasarkan content aslinya (atau apa yang sudah diupdate)
|
||||
# Karena placeholder text berubah seiring streaming, kita harus teliti
|
||||
if app.log[i].get('role') == 'ai' and (app.log[i].get('text') == placeholder_marker or (i > 0 and app.log[i-1].get('role') == 'system' and 'Thinking' in app.log[i-1].get('text', ''))):
|
||||
app.log[i]['text'] = current_text
|
||||
break
|
||||
if app.scroll_follow:
|
||||
app.scroll = 999999
|
||||
|
||||
skill = config.AGENT_SKILLS
|
||||
tool_reminder = None
|
||||
|
||||
response = app.llm.chat(app.messages, tools=app.TOOLS, on_stream_chunk=on_stream_chunk, tool_reminder=tool_reminder)
|
||||
|
||||
# Hapus "Thinking..." log
|
||||
for i in range(len(app.log) - 1, -1, -1):
|
||||
if app.log[i].get('role') == 'system' and 'Thinking' in app.log[i].get('text', ''):
|
||||
app.log.pop(i)
|
||||
break
|
||||
|
||||
if response.warning:
|
||||
log(app, "system", f" {response.warning}")
|
||||
|
||||
# Cek apakah response adalah error dari LLM client
|
||||
content = response.content
|
||||
is_error = isinstance(content, str) and (content.startswith("Error:") or content.startswith("HTTP Error:"))
|
||||
|
||||
if is_error:
|
||||
# Hapus "Thinking..." log dan placeholder
|
||||
for i in range(len(app.log) - 1, -1, -1):
|
||||
if app.log[i].get('role') == 'system' and 'Thinking' in app.log[i].get('text', ''):
|
||||
app.log.pop(i)
|
||||
break
|
||||
if placeholder_marker is not None:
|
||||
for i in range(len(app.log) - 1, -1, -1):
|
||||
if app.log[i].get('role') == 'ai':
|
||||
text = app.log[i].get('text', '')
|
||||
if placeholder_marker in text or text == "" or text == "...":
|
||||
app.log.pop(i)
|
||||
break
|
||||
log(app, "error", content)
|
||||
_add_msg(app, "assistant", content)
|
||||
log(app, "sep", "")
|
||||
ntro.end(stamp)
|
||||
app.agent_done.set()
|
||||
return
|
||||
|
||||
# Cek apakah ada tool_calls
|
||||
has_tool_calls = bool(response.tool_calls)
|
||||
|
||||
if has_tool_calls:
|
||||
# Hapus placeholder jika ada tool_calls (cari semua AI log yang mungkin placeholder)
|
||||
if placeholder_marker is not None:
|
||||
for i in range(len(app.log) - 1, -1, -1):
|
||||
# Jika ini adalah log AI yang muncul tepat setelah "Thinking..." atau contains placeholder marker
|
||||
if app.log[i].get('role') == 'ai':
|
||||
text = app.log[i].get('text', '')
|
||||
# Hapus jika text-nya adalah placeholder atau jika itu adalah entry AI yang baru saja kita buat untuk streaming
|
||||
if placeholder_marker in text or text == "" or text == "...":
|
||||
app.log.pop(i)
|
||||
|
||||
_add_msg(app, "assistant", response.content, tool_calls=response.tool_calls)
|
||||
|
||||
# Log content AI dulu (penjelasan sebelum tools)
|
||||
if response.content and response.content.strip():
|
||||
log(app, "ai", response.content)
|
||||
|
||||
# Log dan execute tool_calls
|
||||
for tc in response.tool_calls:
|
||||
tname = tc["function"]["name"]
|
||||
targs = tc["function"]["arguments"]
|
||||
log(app, "tool_call", json.dumps({
|
||||
"name": tname,
|
||||
"arguments": targs,
|
||||
}))
|
||||
if app.scroll_follow:
|
||||
app.scroll = 999999
|
||||
result = agent_loop.execute_tool(tc, app.TOOL_HANDLERS)
|
||||
if isinstance(result, ImagePayload):
|
||||
_add_msg(app, "tool", [
|
||||
{"type": "text", "text": f"[Image loaded: {result.path}]"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:{result.mime};base64,{result.data}"}}
|
||||
], tool_call_id=tc["id"])
|
||||
else:
|
||||
_add_msg(app, "tool", str(result), tool_call_id=tc["id"])
|
||||
else:
|
||||
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
|
||||
if has_content:
|
||||
if isinstance(response.content, list):
|
||||
log(app, "ai", "[Image generated]")
|
||||
_add_msg(app, "assistant", response.content)
|
||||
log(app, "sep", "")
|
||||
ntro.end(stamp)
|
||||
app.agent_done.set()
|
||||
return
|
||||
ntro.end(stamp_step)
|
||||
|
||||
log(app, "error", "Max iterations reached without final answer.")
|
||||
_add_msg(app, "assistant", "Max iterations reached without final answer.")
|
||||
ntro.end(stamp)
|
||||
app.agent_done.set()
|
||||
|
||||
|
||||
app.agent_done.set()
|
||||
ntro.end(stamp)
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@ class HendrikTUI:
|
||||
self.talk_done = threading.Event()
|
||||
self._talk_thread: threading.Thread | None = None
|
||||
self._talk_result: dict | None = None
|
||||
# Kode tombol rekam dari config (None = tombol tidak valid/fitur mati)
|
||||
# Kode tombol rekam: dipatok tetap F9 (lihat ACTIONS["push_to_talk"])
|
||||
from .actions import ACTIONS as _ACTIONS
|
||||
self.talk_key = _ACTIONS["push_to_talk"]["key"]
|
||||
self.talk_label = _ACTIONS["push_to_talk"]["shortcut"]
|
||||
@ -209,22 +209,28 @@ class HendrikTUI:
|
||||
if i > 0:
|
||||
log(self, "sep", "")
|
||||
log(self, "user", msg["content"])
|
||||
mi = msg.get("model_info")
|
||||
if mi:
|
||||
self.log[-1]["model_info"] = (mi.get("provider"), mi.get("model"))
|
||||
elif msg["role"] == "assistant":
|
||||
mi = msg.get("model_info")
|
||||
pair = (mi.get("provider"), mi.get("model")) if mi else None
|
||||
# Log tool calls jika ada
|
||||
if msg.get("tool_calls"):
|
||||
for tc in msg["tool_calls"]:
|
||||
tcl = 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({
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"]["arguments"],
|
||||
}))
|
||||
}), model_info=pair,
|
||||
usage=msg.get("usage") if last else None)
|
||||
# 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
|
||||
if next_role in ("user", None):
|
||||
if msg.get("content"):
|
||||
log(self, "ai", msg["content"])
|
||||
log(self, "ai", msg["content"], usage=msg.get("usage"))
|
||||
if pair:
|
||||
self.log[-1]["model_info"] = pair
|
||||
elif msg["role"] == "tool":
|
||||
# Tool result - tidak perlu di-log secara eksplisit
|
||||
pass
|
||||
@ -283,8 +289,9 @@ class HendrikTUI:
|
||||
key = keycodes.read_key(stdscr, timeout_ms)
|
||||
except KeyboardInterrupt:
|
||||
if self.processing:
|
||||
self.llm.cancel_requested = True
|
||||
self.agent_done.set()
|
||||
# Sama seperti Esc: cukup minta pembatalan, biarkan thread
|
||||
# agen yang membersihkan log lalu menandai dirinya selesai.
|
||||
self.llm.cancel()
|
||||
else:
|
||||
break
|
||||
key = -1
|
||||
|
||||
@ -263,30 +263,6 @@ def read_key(stdscr, timeout_ms: int = -1) -> int:
|
||||
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:
|
||||
return _KEY_ALT_BASE <= key <= _KEY_ALT_BASE + 126
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ C_INPUT_BORDER = theme.C_INPUT_BORDER
|
||||
C_STATUS_INFO = theme.C_STATUS_INFO
|
||||
C_WELCOME = theme.C_WELCOME
|
||||
C_TOOL_CALL = theme.C_TOOL_CALL
|
||||
C_USAGE = theme.C_USAGE
|
||||
|
||||
|
||||
def draw(app, stdscr):
|
||||
@ -55,6 +56,48 @@ def draw_header(app, stdscr):
|
||||
draw_menubar(app, stdscr)
|
||||
|
||||
|
||||
def _model_info_line(item):
|
||||
"""Baris " Provider - Model " dari entri log; "" jika info tidak ada.
|
||||
|
||||
Dipakai bersama oleh blok `ai` dan `tool_call` agar tampilan konsisten."""
|
||||
model_info = item.get("model_info")
|
||||
if not model_info:
|
||||
return ""
|
||||
p_name, m_name = model_info
|
||||
if p_name:
|
||||
return f" {p_name} - {m_name} "
|
||||
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):
|
||||
# Area chat — dari baris 2 sampai baris (h - 11).
|
||||
# Bisa di-scroll dengan Page Up / Page Down.
|
||||
@ -136,23 +179,26 @@ def draw_chat(app, stdscr):
|
||||
_add_blank()
|
||||
|
||||
if role == "user":
|
||||
model_info = item.get("model_info", None)
|
||||
if model_info:
|
||||
p_name, m_name = model_info
|
||||
if p_name:
|
||||
info_line = f" {p_name} - {m_name} "
|
||||
else:
|
||||
info_line = f" {m_name} "
|
||||
else:
|
||||
info_line = " Unknown - Model info not found "
|
||||
_add_row([(C_USER, info_line, False)])
|
||||
label = f" You ({item['time']}) "
|
||||
_add_row([(C_USER, label)])
|
||||
_add_row([(C_USER, f" You ({item['time']}) ")])
|
||||
_wrap_render(text, indent=1, color=C_INPUT, bold=False)
|
||||
elif role == "ai":
|
||||
label = f" {app.character_name} ({item['time']}) "
|
||||
info_line = _model_info_line(item)
|
||||
if info_line:
|
||||
_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']}) "
|
||||
_add_row([(C_AI, label)])
|
||||
_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":
|
||||
lines = text.split("\n")
|
||||
_add_row([(C_SYSTEM, lines[0])])
|
||||
@ -165,6 +211,7 @@ def draw_chat(app, stdscr):
|
||||
elif role == "tool_call":
|
||||
# Format:
|
||||
# (blank line)
|
||||
# Ollama Cloud - gemma4:31b-cloud
|
||||
# Hendrik run_bash (HH:MM) ← "Hendrik" hijau, "run_bash (HH:MM)" kuning
|
||||
# { ← arguments indent 1 spasi, kuning
|
||||
# "command": "ls -la"
|
||||
@ -173,6 +220,9 @@ def draw_chat(app, stdscr):
|
||||
# Blank line di atas (kecuali sebelumnya sudah blank dari role lain)
|
||||
if idx > 0 and app.log[idx - 1]["role"] not in ("sep", "welcome"):
|
||||
_add_blank()
|
||||
info_line = _model_info_line(item)
|
||||
if info_line:
|
||||
_add_row([(C_AI, info_line, False)])
|
||||
try:
|
||||
tc = json.loads(text)
|
||||
tname = tc["name"]
|
||||
@ -198,6 +248,11 @@ def draw_chat(app, stdscr):
|
||||
(C_TOOL_CALL, "unknown"),
|
||||
(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
|
||||
_add_blank()
|
||||
elif role == "error":
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
# talk.py — Push-to-Talk (ASR/STT) untuk TUI.
|
||||
#
|
||||
# Alur:
|
||||
# * F9 (configurable) → mulai rekam (ffmpeg subprocess, non-blocking).
|
||||
# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil DISISIPKAN
|
||||
# ke form input pada posisi kursor, TIDAK submit.
|
||||
# * F9 (dipatok, tidak configurable) → mulai rekam (ffmpeg subprocess, non-blocking).
|
||||
# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil
|
||||
# disisipkan ke form input pada posisi kursor,
|
||||
# TIDAK submit (default).
|
||||
# * Backspace / Esc → batal: rekaman dibuang / hasil transkrip diabaikan.
|
||||
# * config.asr.auto_submit: true → hasil transkrip langsung dikirim sebagai
|
||||
# pesan (setara Ctrl+Enter). Jika form sudah terisi
|
||||
# saat transkrip selesai, hasil tetap disisipkan.
|
||||
#
|
||||
# State disimpan di app:
|
||||
# app.talk_recorder — instance Recorder saat merekam, else None
|
||||
@ -18,15 +22,16 @@ import threading
|
||||
import config
|
||||
from tools import asr as asr_mod
|
||||
|
||||
from .agent import log
|
||||
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():
|
||||
"""Label tombol rekam untuk pesan UI (dari config.asr.record_key)."""
|
||||
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()
|
||||
"""Label tombol rekam untuk pesan UI (selalu 'F9')."""
|
||||
return TALK_SHORTCUT_LABEL
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers ---
|
||||
@ -209,7 +214,8 @@ def shutdown(app):
|
||||
|
||||
|
||||
def finish_talk(app, stdscr):
|
||||
"""Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil."""
|
||||
"""Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil (atau
|
||||
langsung submit bila config.asr.auto_submit aktif)."""
|
||||
res = getattr(app, "_talk_result", None)
|
||||
app._talk_result = None
|
||||
if getattr(app, "talk_cancelled", False):
|
||||
@ -222,6 +228,27 @@ def finish_talk(app, stdscr):
|
||||
if not text:
|
||||
log(app, "system", " Tidak ada ucapan terdeteksi.")
|
||||
return
|
||||
if config.ASR_AUTO_SUBMIT:
|
||||
# Auto-submit: taruh teks di buffer lalu kirim (submit() yang
|
||||
# membersihkan buffer). Jika user sudah mengetik sesuatu sebelum
|
||||
# transkrip selesai, teks tidak ditimpa — disisipkan saja.
|
||||
if not "\n".join(app.input_buffer).strip():
|
||||
app.input_buffer = text.split("\n")
|
||||
app.input_line = len(app.input_buffer) - 1
|
||||
app.input_col = len(app.input_buffer[-1])
|
||||
submit(app, stdscr)
|
||||
log(app, "system",
|
||||
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
|
||||
"teks otomatis dikirim")
|
||||
else:
|
||||
insert_text(app, text)
|
||||
log(app, "system",
|
||||
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
|
||||
"form sedang terisi \u2014 hasil disisipkan, tidak auto-submit "
|
||||
"(Ctrl+Enter untuk kirim)")
|
||||
if app.scroll_follow:
|
||||
app.scroll = 999999
|
||||
return
|
||||
if insert_text(app, text):
|
||||
log(app, "system",
|
||||
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
|
||||
|
||||
@ -21,6 +21,7 @@ C_HINT_DISABLED = 13 # hint disabled (abu-abu)
|
||||
C_WELCOME = 14 # welcome art: light blue
|
||||
C_TOOL_CALL = 15 # tool call: kuning terang
|
||||
C_TOOL_RESULT = 16 # tool result: magenta muda
|
||||
C_USAGE = 22 # token usage: abu-abu
|
||||
|
||||
# -- Menu bar colors (id 17+) --
|
||||
C_MENU = 17 # background menubar (sama dengan header)
|
||||
@ -49,6 +50,7 @@ def init_colors():
|
||||
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_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta
|
||||
curses.init_pair(C_USAGE, 8, -1) # abu-abu di atas bg default
|
||||
|
||||
# Menu bar
|
||||
curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
"""Agent loop terpusat — satu engine untuk semua interface (TUI, GUI, service).
|
||||
|
||||
Dua boundary pemisah, supaya engine tidak pernah mengenal interface:
|
||||
|
||||
SessionStore → ke mana pesan percakapan ditulis (list aktif + persist opsional)
|
||||
AgentObserver → siapa yang melihat prosesnya (log, streaming, timing)
|
||||
|
||||
Engine sengaja tetap sync (semua handler tool saat ini sync; MCP masih jauh).
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import config
|
||||
from lib.personality import PERSONALITY
|
||||
from tools.vision import ImagePayload
|
||||
from scripts import loggorilla
|
||||
|
||||
def _ts():
|
||||
return datetime.now().strftime('%H:%M:%S')
|
||||
|
||||
def _cap_output(result):
|
||||
# Jaring pengaman ukuran output tool. Hormati semantics config:
|
||||
# AGENT_MAX_TOOL_OUTPUT <= 0 = unlimited (tidak dipotong).
|
||||
limit = getattr(config, "AGENT_MAX_TOOL_OUTPUT", 0)
|
||||
if not limit or limit <= 0:
|
||||
return result
|
||||
@ -39,15 +44,265 @@ def execute_tool(tool_call, TOOL_HANDLERS):
|
||||
except Exception as e:
|
||||
return _cap_output(f'Error executing tool: {str(e)}')
|
||||
|
||||
def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on_tool_calls=None, tool_reminder=None):
|
||||
|
||||
# --------------------------------------------------------------- contracts ---
|
||||
|
||||
class SessionStore:
|
||||
"""Tujuan tulis untuk setiap pesan percakapan selama satu loop berjalan.
|
||||
|
||||
`messages` state aktif yang dikirim ke LLM (list dict).
|
||||
`on_append` dipanggil setelah tiap pesan masuk — untuk persistensi
|
||||
(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`
|
||||
False (default): kunci `model_info` dibuang sebelum pesan
|
||||
masuk list, jadi payload ke provider identik dengan sebelum
|
||||
refactor. Interface yang menampilkan info model menyalakannya.
|
||||
"""
|
||||
|
||||
def __init__(self, messages, on_append=None, keep_model_info=False,
|
||||
on_truncate=None):
|
||||
self.messages = messages
|
||||
self._on_append = on_append
|
||||
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):
|
||||
if not self.keep_model_info:
|
||||
message.pop('model_info', None)
|
||||
message.pop('usage', None) # metadata display, bukan payload LLM
|
||||
self.messages.append(message)
|
||||
self.turn_appends += 1
|
||||
if self._on_append:
|
||||
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:
|
||||
# Hasil satu kali jalan loop.
|
||||
def __init__(self, content=None, session_ended=False, model_info=None, warning=None,
|
||||
is_multimodal=False, usage=None, cancelled=False, partial=""):
|
||||
self.content = content # str | None
|
||||
self.session_ended = session_ended # tool end_session pernah dipanggil
|
||||
self.model_info = model_info or {} # model yang benar-benar menjawab
|
||||
self.warning = warning # mis. fallback chain model
|
||||
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:
|
||||
"""Hook tampilan. Semua no-op — subclass cukup menimpa yang dibutuhkan.
|
||||
|
||||
Engine tidak pernah mencetak atau menyentuh UI; seluruh efek visual hidup
|
||||
di sini, sehingga interface baru tinggal membuat subclass sendiri.
|
||||
"""
|
||||
|
||||
# True bila observer membutuhkan delta streaming dari LLM client.
|
||||
wants_streaming = False
|
||||
|
||||
def on_run_start(self):
|
||||
pass
|
||||
|
||||
def on_run_end(self):
|
||||
pass
|
||||
|
||||
def on_step_start(self, step):
|
||||
pass
|
||||
|
||||
def on_response(self, model_info, warning, usage=None):
|
||||
"""llm.chat() sudah kembali, sebelum engine memproses response.
|
||||
|
||||
`usage` : token pemakaian satu permintaan (prompt/completion/total),
|
||||
None jika provider tidak mengirimnya."""
|
||||
pass
|
||||
|
||||
def on_step_end(self, step):
|
||||
"""Satu iterasi selesai dan loop berlanjut ke iterasi berikutnya."""
|
||||
pass
|
||||
|
||||
def on_stream_chunk(self, chunk):
|
||||
pass
|
||||
|
||||
def on_tool_calls(self, content, names):
|
||||
"""Assistant menjadwalkan tool call; `content` narasi sebelumnya."""
|
||||
pass
|
||||
|
||||
def on_tool_call(self, name, arguments):
|
||||
pass
|
||||
|
||||
def on_final(self, result):
|
||||
pass
|
||||
|
||||
def on_max_iterations(self, max_iterations=None):
|
||||
pass
|
||||
|
||||
def on_cancelled(self, result):
|
||||
"""Turn dihentikan user sebelum ada jawaban final."""
|
||||
pass
|
||||
|
||||
|
||||
class ConsoleObserver(AgentObserver):
|
||||
"""Observer default proses headless (service bot): log gorilla ke stdout.
|
||||
|
||||
`on_tool_calls` mengirim narasi assistant sebelum tool dijalankan —
|
||||
dipakai Telegram/XMPP untuk mengirim pesan interim ke chat.
|
||||
"""
|
||||
|
||||
def __init__(self, on_tool_calls=None):
|
||||
self._on_tool_calls = on_tool_calls
|
||||
|
||||
def on_step_start(self, step):
|
||||
loggorilla.prcss("agent_loop", f"Step {step + 1} — calling LLM...")
|
||||
|
||||
def on_tool_calls(self, content, names):
|
||||
loggorilla.fyinf("agent_loop", f"Using tools: {', '.join(names)}")
|
||||
if self._on_tool_calls:
|
||||
self._on_tool_calls(content)
|
||||
|
||||
def on_final(self, result):
|
||||
# Content None = respons final kosong (mis. sesudah end_session):
|
||||
# tidak ada yang dicetak, sama seperti sebelumnya.
|
||||
if result.content is None:
|
||||
return
|
||||
if result.is_multimodal:
|
||||
loggorilla.prcss("agent_loop", "Response generated (multimodal)")
|
||||
else:
|
||||
loggorilla.prcss("agent_loop",
|
||||
f"Response generated ({len(result.content)} chars)")
|
||||
|
||||
def on_max_iterations(self, max_iterations=None):
|
||||
loggorilla.prcss("agent_loop",
|
||||
f"Max iterations ({max_iterations}) reached")
|
||||
|
||||
def on_cancelled(self, result):
|
||||
loggorilla.prcss("agent_loop", "Turn cancelled by user")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- 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,
|
||||
observer=None, tool_reminder=None, disable_reasoning=None):
|
||||
"""Jalankan loop LLM <-> tool sampai ada jawaban final atau batas iterasi.
|
||||
|
||||
`store` : SessionStore (bukan list mentah — biar persistensi eksplisit).
|
||||
`observer` : None → ConsoleObserver (headless, tanpa streaming).
|
||||
`disable_reasoning` None → ikut PERSONALITY (yaml karakter).
|
||||
|
||||
Return AgentResult. Engine tidak mengklasifikasikan konten sebagai error;
|
||||
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:
|
||||
observer = ConsoleObserver()
|
||||
if disable_reasoning is None:
|
||||
disable_reasoning = PERSONALITY.disable_reasoning
|
||||
|
||||
session_ended = False
|
||||
model_info = {}
|
||||
warning = 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()
|
||||
|
||||
for step in range(max_iterations):
|
||||
loggorilla.prcss("agent_loop", f"Step {step + 1} — calling LLM...")
|
||||
|
||||
disable_reasoning = PERSONALITY.disable_reasoning
|
||||
|
||||
response = llm_client.chat(session.messages, tools=TOOLS, disable_reasoning=disable_reasoning, tool_reminder=tool_reminder)
|
||||
# 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)
|
||||
|
||||
response = llm_client.chat(
|
||||
store.messages,
|
||||
tools=TOOLS,
|
||||
on_stream_chunk=stream_cb,
|
||||
disable_reasoning=disable_reasoning,
|
||||
tool_reminder=tool_reminder,
|
||||
)
|
||||
|
||||
# Model final diambil dari response (bukan state llm saat ini) supaya
|
||||
# auto-switch / rotasi key / fallback tercatat dengan benar.
|
||||
model_info = getattr(response, "model_info", None) or {}
|
||||
warning = getattr(response, "warning", None)
|
||||
usage = getattr(response, "usage", None)
|
||||
_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:
|
||||
amsg = {
|
||||
@ -55,20 +310,30 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
|
||||
'content': response.content,
|
||||
'tool_calls': response.tool_calls,
|
||||
}
|
||||
session.messages.append(amsg)
|
||||
if 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)
|
||||
|
||||
tnames = [tc['function']['name'] for tc in response.tool_calls]
|
||||
loggorilla.fyinf("agent_loop", f"Using tools: {', '.join(tnames)}")
|
||||
|
||||
if on_tool_calls:
|
||||
on_tool_calls(response.content or "")
|
||||
observer.on_tool_calls(response.content or "",
|
||||
[tc['function']['name'] 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'])
|
||||
if tc['function']['name'] == 'end_session':
|
||||
session_ended = True
|
||||
result = execute_tool(tc, TOOL_HANDLERS)
|
||||
if isinstance(result, ImagePayload):
|
||||
session.messages.append({
|
||||
store.append({
|
||||
'role': 'tool',
|
||||
'tool_call_id': tc['id'],
|
||||
'content': [
|
||||
@ -77,30 +342,59 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
|
||||
]
|
||||
})
|
||||
else:
|
||||
session.messages.append({
|
||||
store.append({
|
||||
'role': 'tool',
|
||||
'tool_call_id': tc['id'],
|
||||
'content': str(result),
|
||||
})
|
||||
else:
|
||||
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
|
||||
if has_content:
|
||||
session.messages.append({'role': 'assistant', 'content': response.content})
|
||||
if isinstance(response.content, list):
|
||||
text_parts = [b.get('text', '') for b in response.content
|
||||
if isinstance(b, dict) and b.get('type') == 'text']
|
||||
text = '\n'.join(text_parts) if text_parts else "[Image generated]"
|
||||
loggorilla.prcss("agent_loop", "Response generated (multimodal)")
|
||||
return text, session_ended
|
||||
loggorilla.prcss("agent_loop", f"Response generated ({len(response.content)} chars)")
|
||||
return response.content, session_ended
|
||||
# Respons kosong: tetap bawa flag session_ended — bisa jadi
|
||||
# end_session sudah dipanggil di iterasi sebelumnya.
|
||||
return None, session_ended
|
||||
if _cancelled(llm_client):
|
||||
return _stop(response.content or "")
|
||||
observer.on_step_end(step)
|
||||
continue
|
||||
|
||||
loggorilla.prcss("agent_loop", f"Max iterations ({max_iterations}) reached")
|
||||
session.messages.append({
|
||||
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
|
||||
if has_content:
|
||||
is_multimodal = isinstance(response.content, list)
|
||||
msg = {'role': 'assistant', 'content': response.content}
|
||||
if 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)
|
||||
|
||||
if is_multimodal:
|
||||
text_parts = [b.get('text', '') for b in response.content
|
||||
if isinstance(b, dict) and b.get('type') == 'text']
|
||||
text = '\n'.join(text_parts) if text_parts else "[Image generated]"
|
||||
else:
|
||||
text = response.content
|
||||
|
||||
final = AgentResult(
|
||||
content=text,
|
||||
session_ended=session_ended,
|
||||
model_info=model_info,
|
||||
warning=warning,
|
||||
is_multimodal=is_multimodal,
|
||||
usage=dict(turn_usage) if any_usage else None,
|
||||
)
|
||||
observer.on_final(final)
|
||||
observer.on_run_end()
|
||||
return final
|
||||
|
||||
# Respons kosong: tetap bawa flag session_ended — bisa jadi
|
||||
# end_session sudah dipanggil di iterasi sebelumnya.
|
||||
empty = AgentResult(content=None, session_ended=session_ended,
|
||||
model_info=model_info, warning=warning)
|
||||
observer.on_final(empty)
|
||||
observer.on_run_end()
|
||||
return empty
|
||||
|
||||
store.append({
|
||||
'role': 'assistant',
|
||||
'content': 'Max iterations reached without final answer.',
|
||||
})
|
||||
return None, session_ended
|
||||
observer.on_max_iterations(max_iterations)
|
||||
observer.on_run_end()
|
||||
return AgentResult(content=None, session_ended=session_ended, model_info=model_info, warning=warning)
|
||||
|
||||
@ -13,7 +13,6 @@ tekan tombol → bicara → stop → teks hasil transkripsi masuk ke form input
|
||||
## Konfigurasi (config.yaml, blok `asr:`)
|
||||
asr:
|
||||
record_dir : "~/.config/hendrik/recordings" # lokasi file rekaman
|
||||
record_key : f9 # f1..f12 / ctrl-<huruf>
|
||||
record_backend : pulse # pulse | alsa | oss | windows (dshow)
|
||||
device : default
|
||||
windows_mic : "" # backend=windows: nama device dshow
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 9.1 KiB |
@ -32,5 +32,7 @@ mindmapDiagram {
|
||||
***_ Untuk kredensial service yang di gunakan
|
||||
** model.yaml <<done>>
|
||||
***_ Untuk menetapkan model set default yang digunakan
|
||||
** instruction <<done>>
|
||||
***_ Private instruction directory
|
||||
|
||||
@endmindmap
|
||||
BIN
plan/milestone/12-speech-features.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
55
plan/milestone/12-speech-features.puml
Normal file
@ -0,0 +1,55 @@
|
||||
@startmindmap
|
||||
|
||||
<style>
|
||||
mindmapDiagram {
|
||||
//FontColor white
|
||||
.done {
|
||||
BackgroundColor green
|
||||
BackgroundColor lightgreen
|
||||
//FontColor black
|
||||
}
|
||||
.unknown {
|
||||
BackgroundColor #FFBBCC
|
||||
BackgroundColor purple
|
||||
FontColor white
|
||||
}
|
||||
.skip {
|
||||
BackgroundColor #FFBBCC
|
||||
BackgroundColor red
|
||||
FontColor white
|
||||
}
|
||||
.info {
|
||||
BackgroundColor blue
|
||||
BackgroundColor lightblue
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
;top to bottom direction
|
||||
|
||||
* Full-Duplex Model
|
||||
**_ Voice Activity Detection
|
||||
**_ Noise interruption detector included
|
||||
** Mic & Voice Frontend
|
||||
** Models
|
||||
*** GPT-Live-1
|
||||
|
||||
* Speech <<done>>
|
||||
** Push to talk <<done>>
|
||||
*** STT (ASR) <<done>>
|
||||
**** Inference <<done>>
|
||||
***** OpenRouter API Inference <<done>>
|
||||
***** sherpa-onnx (local)
|
||||
**** Model
|
||||
***** Qwen3-ASR 1.7B int8
|
||||
*** TTS
|
||||
**** Inference
|
||||
***** sherpa-onnx
|
||||
**** Model
|
||||
***** ZipVoice distill int8
|
||||
** Live
|
||||
*** Full-Duplex
|
||||
**** Inference
|
||||
**** Model
|
||||
|
||||
@endmindmap
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 19 KiB |
@ -28,12 +28,10 @@ mindmapDiagram {
|
||||
;top to bottom direction
|
||||
|
||||
* root <<done>>
|
||||
** .env <<done>>
|
||||
** .env.example <<done>>
|
||||
** .gitignore <<done>>
|
||||
** LICENSE <<done>>
|
||||
** config.default.yaml <<done>>
|
||||
** config.py
|
||||
** config.yaml <<done>>
|
||||
** config.default.yaml
|
||||
** hendrik <<done>>
|
||||
** hendrik.py <<done>>
|
||||
** requirements.txt <<done>>
|
||||
|
||||
37
plan/milestone/credit.puml
Normal file
@ -0,0 +1,37 @@
|
||||
@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
|
||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 184 KiB |
@ -57,30 +57,6 @@ mindmapDiagram {
|
||||
** Detailed
|
||||
***_ Give thorough, detailed answers with explanations.
|
||||
|
||||
* Full-Duplex Model
|
||||
**_ Voice Activity Detection
|
||||
**_ Noise interruption detector included
|
||||
** Mic & Voice Frontend
|
||||
** Models
|
||||
*** GPT-Live-1
|
||||
|
||||
* Speech
|
||||
** Push to talk
|
||||
*** STT (ASR)
|
||||
**** Inference
|
||||
***** sherpa-onnx
|
||||
**** Model
|
||||
***** Qwen3-ASR 1.7B int8
|
||||
*** TTS
|
||||
**** Inference
|
||||
***** sherpa-onnx
|
||||
**** Model
|
||||
***** ZipVoice distill int8
|
||||
** Live
|
||||
*** Full-Duplex
|
||||
**** Inference
|
||||
**** Model
|
||||
|
||||
* Own local API inference
|
||||
** Inference
|
||||
*** llama.cpp
|
||||
|
||||
@ -1,9 +1,161 @@
|
||||
import json
|
||||
import socket
|
||||
from lib import gadget
|
||||
import threading
|
||||
from lib import gadget, moop
|
||||
import urllib.request
|
||||
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):
|
||||
"""
|
||||
- 'auth' : 401/403 → rotasi key (coba key berikutnya di provider yang sama)
|
||||
@ -21,7 +173,11 @@ class LLMClient:
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
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', ...)
|
||||
self._chains: dict[str, list[dict]] = {}
|
||||
@ -29,12 +185,56 @@ class LLMClient:
|
||||
self._chain_key: str | None = None
|
||||
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:
|
||||
def __init__(self, msg):
|
||||
def __init__(self, msg, model_info=None):
|
||||
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.tool_calls = msg.get('tool_calls', None) # Ambil tool calls
|
||||
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
|
||||
# dengan model awal permintaan karena auto-switch/fallback.
|
||||
self.model_info = model_info or {}
|
||||
|
||||
# ------------------------------------------------------------ config ---
|
||||
|
||||
@ -86,6 +286,23 @@ class LLMClient:
|
||||
self.model = cand.get("model", "")
|
||||
self.api_key = cand.get("api_key", "")
|
||||
|
||||
def _model_info(self, cand=None):
|
||||
"""Info model yang dipakai untuk satu permintaan.
|
||||
|
||||
Dari kandidat chain jika ada (provider sudah dibawa moop), jika tidak
|
||||
(klien non-chain) pakai state aktif dan cari nama provider-nya."""
|
||||
if cand:
|
||||
return {
|
||||
"provider": cand.get("provider"),
|
||||
"base_url": cand.get("base_url", ""),
|
||||
"model": cand.get("model", ""),
|
||||
}
|
||||
return {
|
||||
"provider": moop.resolve_provider(None, self.base_url, self.model),
|
||||
"base_url": self.base_url,
|
||||
"model": self.model,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------- chain selection ---
|
||||
|
||||
def _pick_chain_type(self, messages):
|
||||
@ -107,15 +324,26 @@ class LLMClient:
|
||||
|
||||
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
|
||||
return self._chat_once(
|
||||
self.base_url, self.model, self.api_key,
|
||||
messages,
|
||||
tools=tools,
|
||||
on_stream_chunk=on_stream_chunk,
|
||||
disable_reasoning=disable_reasoning,
|
||||
tool_reminder=tool_reminder,
|
||||
)
|
||||
try:
|
||||
result = self._chat_once(
|
||||
self.base_url, self.model, self.api_key,
|
||||
messages,
|
||||
tools=tools,
|
||||
on_stream_chunk=on_stream_chunk,
|
||||
disable_reasoning=disable_reasoning,
|
||||
tool_reminder=tool_reminder,
|
||||
)
|
||||
except StreamCancelled as e:
|
||||
return self._cancelled_message(e.partial)
|
||||
result.model_info = self._model_info()
|
||||
return result
|
||||
|
||||
chain_type = self._pick_chain_type(messages)
|
||||
if chain_type != self._chain_key:
|
||||
@ -176,6 +404,16 @@ class LLMClient:
|
||||
disable_reasoning=disable_reasoning, tool_reminder=tool_reminder,
|
||||
)
|
||||
self._chain_index = idx
|
||||
# Catat model yang benar-benar menjawab (bukan model awal
|
||||
# permintaan) supaya pemanggil bisa menampilkan data yang jujur.
|
||||
result.model_info = self._model_info(cand)
|
||||
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:
|
||||
last = e
|
||||
@ -207,23 +445,36 @@ class LLMClient:
|
||||
# ------------------------------------------------------- single attempt ---
|
||||
|
||||
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"
|
||||
# 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 = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"messages": msgs_clean,
|
||||
"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:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
if tool_reminder:
|
||||
# Inject tool reminder sebagai system message sebelum user message terakhir
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
payload["messages"] = messages[:i] + [
|
||||
for i in range(len(msgs_clean) - 1, -1, -1):
|
||||
if msgs_clean[i].get("role") == "user":
|
||||
payload["messages"] = msgs_clean[:i] + [
|
||||
{"role": "system", "content": tool_reminder}
|
||||
] + messages[i:]
|
||||
] + msgs_clean[i:]
|
||||
break
|
||||
|
||||
# Hanya kirim parameter reasoning jika diminta eksplisit
|
||||
@ -243,82 +494,105 @@ class LLMClient:
|
||||
reasoning_content = ""
|
||||
full_multimodal_content = []
|
||||
stream_started = False
|
||||
usage = None # prompt/completion/total tokens dari chunk terakhir
|
||||
|
||||
my_sock = None
|
||||
try:
|
||||
self.cancel_requested = False
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
# Streaming: baca line by line
|
||||
for line in resp:
|
||||
if self.cancel_requested:
|
||||
# Stream cancellation
|
||||
full_content += "\n\n[Stream cancelled by user]"
|
||||
break
|
||||
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:
|
||||
# Streaming: baca line by line
|
||||
for line in resp:
|
||||
if self._cancel.requested:
|
||||
raise StreamCancelled(full_content)
|
||||
|
||||
line = line.decode('utf-8').strip()
|
||||
if not line or not line.startswith('data: '):
|
||||
continue
|
||||
line = line.decode('utf-8').strip()
|
||||
if not line or not line.startswith('data: '):
|
||||
continue
|
||||
|
||||
data_str = line[6:] # Hapus "data: " prefix
|
||||
if data_str == '[DONE]':
|
||||
break
|
||||
data_str = line[6:] # Hapus "data: " prefix
|
||||
if data_str == '[DONE]':
|
||||
break
|
||||
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Parse delta dari chunk
|
||||
delta = chunk.get('choices', [{}])[0].get('delta', {})
|
||||
finish_reason = chunk.get('choices', [{}])[0].get('finish_reason', None)
|
||||
# Pemakaian token datang di chunk penutup (choices kosong
|
||||
# pada banyak provider) — ambil selama masih None / terbaru.
|
||||
if chunk.get('usage'):
|
||||
usage = chunk['usage']
|
||||
|
||||
# Stream reasoning content jika ada
|
||||
if 'reasoning_content' in delta:
|
||||
reasoning_content += delta['reasoning_content']
|
||||
# Parse delta dari chunk
|
||||
choices = chunk.get('choices') or []
|
||||
delta = choices[0].get('delta', {}) if choices else {}
|
||||
finish_reason = choices[0].get('finish_reason', None) if choices else None
|
||||
|
||||
# Stream tool_calls jika ada
|
||||
if 'tool_calls' in delta:
|
||||
tool_calls = delta['tool_calls']
|
||||
for tc in tool_calls:
|
||||
idx = tc.get('index', 0)
|
||||
# Pastikan list cukup panjang
|
||||
while len(full_tool_calls) <= idx:
|
||||
full_tool_calls.append({
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {"name": "", "arguments": ""}
|
||||
})
|
||||
# Stream reasoning content jika ada
|
||||
if 'reasoning_content' in delta:
|
||||
reasoning_content += delta['reasoning_content']
|
||||
|
||||
# Update ID
|
||||
if 'id' in tc and tc['id']:
|
||||
full_tool_calls[idx]['id'] = tc['id']
|
||||
# Stream tool_calls jika ada
|
||||
if 'tool_calls' in delta:
|
||||
tool_calls = delta['tool_calls']
|
||||
for tc in tool_calls:
|
||||
idx = tc.get('index', 0)
|
||||
# Pastikan list cukup panjang
|
||||
while len(full_tool_calls) <= idx:
|
||||
full_tool_calls.append({
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {"name": "", "arguments": ""}
|
||||
})
|
||||
|
||||
# Update function name
|
||||
if 'function' in tc and 'name' in tc['function']:
|
||||
full_tool_calls[idx]['function']['name'] += tc['function']['name']
|
||||
# Update ID
|
||||
if 'id' in tc and tc['id']:
|
||||
full_tool_calls[idx]['id'] = tc['id']
|
||||
|
||||
# Update arguments
|
||||
if 'function' in tc and 'arguments' in tc['function']:
|
||||
full_tool_calls[idx]['function']['arguments'] += tc['function']['arguments']
|
||||
# Update function name
|
||||
if 'function' in tc and 'name' in tc['function']:
|
||||
full_tool_calls[idx]['function']['name'] += tc['function']['name']
|
||||
|
||||
# Stream content (text response atau multimodal blocks)
|
||||
if 'content' in delta:
|
||||
chunk = delta['content']
|
||||
if isinstance(chunk, str):
|
||||
full_content += chunk or ""
|
||||
if chunk:
|
||||
stream_started = True
|
||||
if on_stream_chunk and chunk:
|
||||
on_stream_chunk(chunk)
|
||||
elif isinstance(chunk, list):
|
||||
full_multimodal_content.extend(chunk)
|
||||
for block in chunk:
|
||||
if isinstance(block, dict) and block.get('type') == 'text':
|
||||
t = block.get('text', '')
|
||||
if t:
|
||||
full_content += t
|
||||
stream_started = True
|
||||
if on_stream_chunk:
|
||||
on_stream_chunk(t)
|
||||
# Update arguments
|
||||
if 'function' in tc and 'arguments' in tc['function']:
|
||||
full_tool_calls[idx]['function']['arguments'] += tc['function']['arguments']
|
||||
|
||||
# Stream content (text response atau multimodal blocks)
|
||||
if 'content' in delta:
|
||||
chunk = delta['content']
|
||||
if isinstance(chunk, str):
|
||||
full_content += chunk or ""
|
||||
if chunk:
|
||||
stream_started = True
|
||||
if on_stream_chunk and chunk:
|
||||
on_stream_chunk(chunk)
|
||||
elif isinstance(chunk, list):
|
||||
full_multimodal_content.extend(chunk)
|
||||
for block in chunk:
|
||||
if isinstance(block, dict) and block.get('type') == 'text':
|
||||
t = block.get('text', '')
|
||||
if t:
|
||||
full_content += t
|
||||
stream_started = True
|
||||
if on_stream_chunk:
|
||||
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
|
||||
if full_multimodal_content:
|
||||
@ -355,7 +629,11 @@ class LLMClient:
|
||||
message['tool_calls'] = valid_tool_calls
|
||||
|
||||
response = {'choices': [{'message': message}]}
|
||||
except StreamCancelled:
|
||||
raise
|
||||
except urllib.error.HTTPError as e:
|
||||
if self._cancel.requested:
|
||||
raise StreamCancelled(full_content)
|
||||
body_text = ""
|
||||
try:
|
||||
body_text = e.read().decode('utf-8', errors='replace')
|
||||
@ -374,19 +652,35 @@ class LLMClient:
|
||||
return result
|
||||
except Exception:
|
||||
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 ""
|
||||
if e.code in (401, 403):
|
||||
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
|
||||
raise _LLMFail("unreachable", f"HTTP {e.code} {e.reason}{detail}")
|
||||
except urllib.error.URLError as e:
|
||||
if self._cancel.requested:
|
||||
raise StreamCancelled(full_content)
|
||||
if stream_started:
|
||||
return self.Message({'content': f"Error: {e.reason}", 'tool_calls': None})
|
||||
raise _LLMFail("unreachable", f"Connection error: {e.reason}")
|
||||
except (TimeoutError, socket.timeout) as e:
|
||||
if self._cancel.requested:
|
||||
raise StreamCancelled(full_content)
|
||||
if stream_started:
|
||||
return self.Message({'content': f"Error: Timeout", 'tool_calls': None})
|
||||
raise _LLMFail("unreachable", f"Timeout")
|
||||
except Exception as e:
|
||||
if self._cancel.requested:
|
||||
raise StreamCancelled(full_content)
|
||||
if stream_started:
|
||||
return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None})
|
||||
raise _LLMFail("unreachable", f"Error: {str(e)}")
|
||||
@ -423,4 +717,28 @@ class LLMClient:
|
||||
message.pop('reasoning_content', None)
|
||||
message.pop('reasoning', None)
|
||||
|
||||
return self.Message(message)
|
||||
result = 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,6 +136,26 @@ class NeoSessionManager:
|
||||
def update_model_info(self, doc_id: int, model_info: dict) -> bool:
|
||||
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
|
||||
def _doc_to_session(doc) -> NeoSession:
|
||||
return NeoSession(
|
||||
|
||||
@ -7,7 +7,7 @@ from datetime import datetime
|
||||
|
||||
import config
|
||||
from services.session_manager import SessionManager
|
||||
from lib.agent_loop import run_agent_loop
|
||||
from lib.agent_loop import run_agent_loop, SessionStore, ConsoleObserver
|
||||
from lib import personality
|
||||
from lib import ragroleplay
|
||||
from scripts import loggorilla
|
||||
@ -259,10 +259,27 @@ class TelegramClient:
|
||||
|
||||
tool_reminder = None
|
||||
|
||||
final_content, should_close = run_agent_loop(
|
||||
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
|
||||
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder
|
||||
# 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(
|
||||
SessionStore(session.messages),
|
||||
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
||||
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
|
||||
tool_reminder=tool_reminder,
|
||||
)
|
||||
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):
|
||||
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang
|
||||
|
||||
@ -8,7 +8,7 @@ from datetime import datetime
|
||||
|
||||
from slixmpp import ClientXMPP
|
||||
from services.session_manager import SessionManager
|
||||
from lib.agent_loop import run_agent_loop
|
||||
from lib.agent_loop import run_agent_loop, SessionStore, ConsoleObserver
|
||||
|
||||
import config
|
||||
from tools.roleplayer import should_respond
|
||||
@ -624,10 +624,24 @@ class XMPPClient(ClientXMPP):
|
||||
|
||||
tool_reminder = None
|
||||
|
||||
final_content, should_close = run_agent_loop(
|
||||
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
|
||||
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder
|
||||
# Turn baru: bersihkan state cancel warisan antar interface/thread
|
||||
# yang berbagi satu LLMClient.
|
||||
self._llm.begin_turn()
|
||||
|
||||
outcome = run_agent_loop(
|
||||
SessionStore(session.messages),
|
||||
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
||||
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
|
||||
tool_reminder=tool_reminder,
|
||||
)
|
||||
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 is_roleplay:
|
||||
@ -709,10 +723,23 @@ class XMPPClient(ClientXMPP):
|
||||
|
||||
tool_reminder = None
|
||||
|
||||
final_content, should_close = run_agent_loop(
|
||||
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
|
||||
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder
|
||||
# Turn baru: bersihkan state cancel warisan antar interface/thread.
|
||||
self._llm.begin_turn()
|
||||
|
||||
outcome = run_agent_loop(
|
||||
SessionStore(session.messages),
|
||||
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
|
||||
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
|
||||
tool_reminder=tool_reminder,
|
||||
)
|
||||
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):
|
||||
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang
|
||||
|
||||
128
tools/instrbase.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""
|
||||
Shared helper untuk varian instruction tool "private per character".
|
||||
|
||||
Base directory yang dipakai:
|
||||
agent/characters/<AGENT_CHARACTER>/instruction/
|
||||
|
||||
Dipakai oleh:
|
||||
tools/smart.py -> instruction_char_use
|
||||
tools/smart_rp.py -> rp_instruction_char_use
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def char_from_config():
|
||||
"""Nama karakter aktif (lowercase) — dibaca saat dipanggil supaya
|
||||
override `--char` / config.load_character() ikut terpakai."""
|
||||
return (getattr(config, "AGENT_CHARACTER", "") or "").strip().lower()
|
||||
|
||||
|
||||
def char_instruction_base(char=None):
|
||||
"""Return (char, base_dir) untuk instruction privat karakter.
|
||||
|
||||
base_dir bernilai None bila tidak ada karakter aktif atau CHARACTERS_DIR
|
||||
tidak tersedia di config."""
|
||||
char = (char or char_from_config())
|
||||
base_dir = None
|
||||
if char:
|
||||
chars_dir = getattr(config, "CHARACTERS_DIR", None)
|
||||
if chars_dir is not None:
|
||||
base_dir = str(chars_dir / char / "instruction")
|
||||
return char, base_dir
|
||||
|
||||
|
||||
def sanitize_path(directory, filename):
|
||||
"""Sanitasi input supaya tetap di dalam base_dir (anti path traversal).
|
||||
Segment kosong, '.' dan '..' dibuang, slash absolut dinetralkan."""
|
||||
parts = [p for p in str(directory).replace('\\', '/').split('/')
|
||||
if p and p not in ('.', '..')]
|
||||
safe_directory = os.path.join(*parts) if parts else ""
|
||||
safe_filename = os.path.basename(str(filename).replace('\\', '/'))
|
||||
return safe_directory, safe_filename
|
||||
|
||||
|
||||
def inside_base(base_dir, file_path):
|
||||
"""Guard tambahan: file harus benar-benar berada di dalam base_dir
|
||||
(menutup celah symlink / penulisan path aneh)."""
|
||||
try:
|
||||
real_base = os.path.realpath(base_dir)
|
||||
real_file = os.path.realpath(file_path)
|
||||
except Exception:
|
||||
return False
|
||||
return real_file == real_base or real_file.startswith(real_base + os.sep)
|
||||
|
||||
|
||||
def list_available(base_dir, limit=50):
|
||||
"""Daftar file (relatif terhadap base_dir) untuk pesan error yang membantu.
|
||||
Return None bila directory privat karakter belum ada sama sekali."""
|
||||
if not base_dir or not os.path.isdir(base_dir):
|
||||
return None
|
||||
found = []
|
||||
for root, _dirs, files in os.walk(base_dir):
|
||||
for fname in sorted(files):
|
||||
rel = os.path.relpath(os.path.join(root, fname), base_dir)
|
||||
found.append(rel.replace(os.sep, "/"))
|
||||
return sorted(found)[:limit]
|
||||
|
||||
|
||||
def missing_hint(char, base_dir):
|
||||
"""Pesan penutup untuk error 'file not found'."""
|
||||
available = list_available(base_dir)
|
||||
if available is None:
|
||||
return (f"The character '{char}' has no private instruction directory yet "
|
||||
f"(expected at {base_dir}).")
|
||||
if available:
|
||||
return f"Available files in '{base_dir}': {', '.join(available)}"
|
||||
return f"The private instruction directory '{base_dir}' exists but is empty."
|
||||
|
||||
|
||||
def read_instruction_file(tool_name, logger, directory, filename, label="instruction",
|
||||
caller="instruction_char_use"):
|
||||
"""Resolver + reader bersama untuk varian char (private per character).
|
||||
|
||||
Args:
|
||||
tool_name: nama tool untuk log (mis. 'tools.smart_rp').
|
||||
logger : loggorilla module, atau None untuk tanpa logging.
|
||||
directory: sub-directory di dalam instruction privat karakter ('' = root).
|
||||
filename : nama file instruksi.
|
||||
label : penanda jenis instruction di pesan error ('instruction' | 'roleplay instruction').
|
||||
|
||||
Returns:
|
||||
Isi file (str) atau string pesan error.
|
||||
"""
|
||||
|
||||
def _log(msg):
|
||||
if logger is not None:
|
||||
logger.fyinf(tool_name, msg)
|
||||
|
||||
_log(f"Calling {caller} with directory='{directory}', filename='{filename}'")
|
||||
|
||||
char, base_dir = char_instruction_base()
|
||||
|
||||
if not char or not base_dir:
|
||||
res = (f"Error: No active character is configured, so there is no private {label} "
|
||||
"directory. Set the character in config.yaml or pass --char <character>.")
|
||||
_log(res)
|
||||
return res
|
||||
|
||||
safe_directory, safe_filename = sanitize_path(directory, filename)
|
||||
file_path = os.path.join(base_dir, safe_directory, safe_filename)
|
||||
|
||||
if (not os.path.isfile(file_path)) or (not inside_base(base_dir, file_path)):
|
||||
res = (f"Error: Character {label} file not found at {file_path}. "
|
||||
f"{missing_hint(char, base_dir)}")
|
||||
_log(f"File not found: {res}")
|
||||
return res
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
_log(f"Successfully read char {label} file for '{char}'. Content snippet: {content[:100]}...")
|
||||
return content
|
||||
except Exception as e:
|
||||
res = f"Error reading character {label} file: {str(e)}"
|
||||
_log(f"Exception occurred: {res}")
|
||||
return res
|
||||
@ -1,7 +1,9 @@
|
||||
import os
|
||||
|
||||
# NOTE: loggorilla dimatikan di tool ini. print()-nya bisa bikin error saat
|
||||
# proses keluar (SCI), jadi semua pemanggilan di bawah hanya di-comment, jangan dihapus.
|
||||
# from scripts import loggorilla
|
||||
from tools import instrbase
|
||||
|
||||
schema_instruction_use = {
|
||||
"type": "function",
|
||||
@ -48,3 +50,43 @@ def instruction_use(directory, filename):
|
||||
res = f"Error reading file: {str(e)}"
|
||||
# loggorilla.fyinf("tools.smart", f"Exception occurred: {res}")
|
||||
return res
|
||||
|
||||
|
||||
# ─── instruction_char_use (private / per-character variant) ────────────────────
|
||||
|
||||
schema_instruction_char_use = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "instruction_char_use",
|
||||
"description": (
|
||||
"Reads a specific instruction file from the PRIVATE 'instruction' directory of the "
|
||||
"active character (agent/characters/<character>/instruction) and returns its content. "
|
||||
"This is the character-scoped variant of instruction_use: use it to load snippets, "
|
||||
"guidelines or system prompt extensions that belong only to the current character, "
|
||||
"instead of the shared/global 'instruction' directory."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The subdirectory within the character's own 'instruction' folder "
|
||||
"(e.g., 'snippets', 'scenes', 'world'). Pass an empty string to read "
|
||||
"directly from the root of that folder."
|
||||
)
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "The name of the instruction file to read (e.g., 'opening.md', 'rules.md')."
|
||||
}
|
||||
},
|
||||
"required": ["directory", "filename"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def instruction_char_use(directory, filename):
|
||||
# loggorilla dimatikan di tool ini (lihat catatan di atas), logger dikirim None.
|
||||
return instrbase.read_instruction_file("tools.smart", None, directory, filename)
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import os
|
||||
|
||||
from scripts import loggorilla
|
||||
from tools import instrbase
|
||||
|
||||
schema_rp_instruction_use = {
|
||||
"type": "function",
|
||||
@ -46,3 +48,46 @@ def rp_instruction_use(directory, filename):
|
||||
res = f"Error reading RP file: {str(e)}"
|
||||
loggorilla.fyinf("tools.smart_rp", f"Exception occurred: {res}")
|
||||
return res
|
||||
|
||||
|
||||
# ─── rp_instruction_char_use (private / per-character variant) ─────────────────
|
||||
|
||||
schema_rp_instruction_char_use = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "rp_instruction_char_use",
|
||||
"description": (
|
||||
"CRITICAL ROLEPLAY VALIDATION TOOL (character-scoped): Use this tool ONLY when the "
|
||||
"character is interacting with a specific object, outfit, or location defined in this "
|
||||
"character's PRIVATE roleplay instruction files, located at "
|
||||
"agent/characters/<character>/instruction. This is the character-scoped variant of "
|
||||
"rp_instruction_use — it must be called to fetch the authoritative description before "
|
||||
"describing the item, to ensure narrative consistency and prevent hallucination. "
|
||||
"DO NOT use if the character is not interacting with a specific listed item."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The subdirectory within the character's own 'instruction' folder "
|
||||
"(e.g., 'rp/item', 'rp/armor', 'rp/world', 'gameplay'). Pass an empty "
|
||||
"string to read directly from the root of that folder."
|
||||
)
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "The name of the instruction file to read (e.g., 'rules.md', 'iron-sword.md')."
|
||||
}
|
||||
},
|
||||
"required": ["directory", "filename"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def rp_instruction_char_use(directory, filename):
|
||||
return instrbase.read_instruction_file(
|
||||
"tools.smart_rp", loggorilla, directory, filename,
|
||||
label="roleplay instruction", caller="rp_instruction_char_use"
|
||||
)
|
||||
|
||||