Migrate chat log to JSONL format
Server changes: - format/block.go: Block struct with JSONL marshaling, RenderBlock for text - Deleted format/event.go, format.go, format_test.go (old delimiter format) - agent/chat.go: Dual logs (rawLog JSONL + textLog rendered text) - agent/chatlog.go: Streaming via flushPartial/closeBlock, skip internal events - fs/spec.go: New files log.raw (JSONL), log (text), block (ID lookup) - Removed chat/chat.raw/chat.search from namespace GUI changes: - chatblockmodel: JSONL parsing via QJsonDocument, removed regex state machine - ollie9pclient: Read from log.raw for replay, removed old streaming code - Added Context type to block enum, filter context/state/usage from display Docs updated: AGENTS.md, system_prompt.md, architecture-*.md, scripts/o
This commit is contained in:
parent
e53d612bdb
commit
57c40d541e
|
|
@ -121,7 +121,11 @@ This is a standing preference, not a per-task instruction. Apply it without aski
|
|||
11. **Peers**: Persistent agents in the same session can be linked via `peeradd`. Links are bidirectional. Agents communicate by writing to `peer/{name}`, which delivers to the target's prompt handler. Only declared peers can be messaged — the `peer/` directory is the access control surface.
|
||||
12. **9P namespace declaration**: `cmd/olliesrv/internal/fs/spec.go` declares the olliesrv namespace. The toolsrv namespace is declared by `cmd/toolsrv/p9.go` using `cmd/toolsrv/internal/server.Spec`; process state and handlers are in `cmd/toolsrv/internal/server/`. Both use the `virtfs` EDSL and `virtfs.BuildTree()`.
|
||||
13. **Working directory (default + override)**: Session cwd is **required** at session creation and is the inheritance root for every agent — the sane default so many agents can work in one directory with zero per-agent config. Agent cwd is an **optional** per-agent override (empty = inherit the session cwd). Because the per-session `toolsrv` sets `cmd.Dir` per tool call, per-agent cwd needs no extra process: `toolsrv` keeps an in-memory `agentCWD` map resolved per call from the `agent=` field (`cmd/toolsrv/internal/server/proc.go`), falling back to the session-level global cwd. The override is set only over the controlled `olliesrv`→`toolsrv` ctl channel (`agentcwd <id> <dir>`), never embedded in a tool-call payload, so the model cannot influence where its own tools run. The map is process-local, so `Agent.SyncCwdToToolServer` re-pushes the override on every (re)connect — profile switch, resume, restore — matching how env and tools are resynced. Set/clear via the agent `cfg` (`cwd=...`) or ctl (`cwd [<dir>|-]`); the GUI Agent Settings dialog shows `(inherit: <sessionCwd>)` as the placeholder.
|
||||
14. **Chat log files**: Each agent exposes multiple views of its conversation history. `chat` and `chat.raw` are **blocking streams** — reads wait for new data, making them suitable for live tailing but not for one-shot inspection. Use `log` for non-blocking reads (returns last 64KB). `chat.search` is an rdwr file: write a block ID, read the matching block. Block IDs are 8-char hex strings generated deterministically from `sha256(sessionID + agentID + counter)`. Block format: `[[[role:name#blockID]]]` header, content, `[[[end]]]` terminator.
|
||||
14. **Chat log files**: Each agent exposes three views of its conversation history via the 9P namespace:
|
||||
- `log.raw` — JSONL stream (one JSON object per line). Blocking; suitable for live tailing. Each line is `{"role":"...","id":"...","content":"..."}` with optional `name`, `format`, and `partial` fields. Partial blocks (`"partial":true`) represent in-progress streaming; they are replaced by the final block when complete.
|
||||
- `log` — Rendered text (last 64KB). Non-blocking; suitable for one-shot inspection. Filters out reasoning, call, and tool blocks; shows only user/assistant/context content.
|
||||
- `block` — Rdwr lookup: write a block ID (8-char hex), read the matching block as JSON. Returns the full block object or an error if not found.
|
||||
Block IDs are 8-char hex strings generated deterministically from `sha256(sessionID + agentID + counter)`.
|
||||
|
||||
## Where to Start
|
||||
|
||||
|
|
|
|||
|
|
@ -68,10 +68,12 @@ type Agent struct {
|
|||
onProcStart func(agentID string, pid int, tool, cmd string) // optional callback for proc start
|
||||
onClear func(agentID string) // optional callback for clear
|
||||
|
||||
// Chat log — the rendered output stream, exposed via 9P.
|
||||
// Chat logs — dual format: JSONL for programmatic access, plain text for humans.
|
||||
chatMu sync.RWMutex
|
||||
chatLog []byte // append-only chat log buffer
|
||||
chatStart int // byte offset of first byte in chatLog (for truncation accounting)
|
||||
rawLog []byte // JSONL format (one Block per line)
|
||||
rawStart int // byte offset of first byte in rawLog (for truncation accounting)
|
||||
textLog []byte // Rendered plain text
|
||||
textStart int // byte offset of first byte in textLog
|
||||
chatVers uint32 // incremented on each append for change detection
|
||||
chatCond *sync.Cond // signaled on new chat data
|
||||
chatSignalMu sync.Mutex
|
||||
|
|
@ -313,8 +315,10 @@ func (ag *Agent) Clear() error {
|
|||
}
|
||||
ag.history = nil
|
||||
ag.chatMu.Lock()
|
||||
ag.chatLog = nil
|
||||
ag.chatStart = 0
|
||||
ag.rawLog = nil
|
||||
ag.rawStart = 0
|
||||
ag.textLog = nil
|
||||
ag.textStart = 0
|
||||
ag.chatVers++
|
||||
ag.chatMu.Unlock()
|
||||
ag.chatCond.Broadcast()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
// chat.go — Chat log storage and streaming.
|
||||
//
|
||||
// The chat log is an append-only buffer of formatted output. Readers can
|
||||
// stream from any offset; StreamChat returns a read function that blocks
|
||||
// until data is available.
|
||||
// Two logs are maintained:
|
||||
// - rawLog: JSONL format (one Block per line) for programmatic access
|
||||
// - textLog: Rendered plain text for human reading
|
||||
//
|
||||
// Both are append-only buffers with overflow handling.
|
||||
|
||||
package agent
|
||||
|
||||
|
|
@ -13,16 +15,14 @@ import (
|
|||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"ollie/format"
|
||||
)
|
||||
|
||||
// --- Chat log methods ---
|
||||
|
||||
const maxChatLogBytes = 16 * 1024 * 1024
|
||||
const maxLogBytes = 16 * 1024 * 1024
|
||||
|
||||
// NextBlockID returns the next deterministic block ID and increments the counter.
|
||||
// Block IDs are sha256(sessionID + agentID + counter)[:8] — deterministic given
|
||||
// the same session, agent, and sequence. Counter starts at 0 for fresh agents,
|
||||
// or at the replayed block count after session restore.
|
||||
// Block IDs are sha256(sessionID + agentID + counter)[:8].
|
||||
func (ag *Agent) NextBlockID() string {
|
||||
n := atomic.AddUint64(&ag.blockCounter, 1) - 1
|
||||
h := sha256.New()
|
||||
|
|
@ -34,20 +34,17 @@ func (ag *Agent) NextBlockID() string {
|
|||
return hex.EncodeToString(h.Sum(nil))[:8]
|
||||
}
|
||||
|
||||
// AppendChat appends data to the chat log and notifies stream readers.
|
||||
func (ag *Agent) AppendChat(data []byte) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
// AppendBlock writes a block to both logs and notifies stream readers.
|
||||
func (ag *Agent) AppendBlock(b format.Block) {
|
||||
raw := format.MarshalBlock(b)
|
||||
text := []byte(format.RenderBlock(b))
|
||||
|
||||
ag.chatMu.Lock()
|
||||
ag.chatLog = append(ag.chatLog, data...)
|
||||
if len(ag.chatLog) > maxChatLogBytes {
|
||||
drop := len(ag.chatLog) - maxChatLogBytes
|
||||
ag.chatLog = append([]byte(nil), ag.chatLog[drop:]...)
|
||||
ag.chatStart += drop
|
||||
}
|
||||
ag.rawLog = appendWithOverflow(ag.rawLog, raw, maxLogBytes, &ag.rawStart)
|
||||
ag.textLog = appendWithOverflow(ag.textLog, text, maxLogBytes, &ag.textStart)
|
||||
ag.chatVers++
|
||||
ag.chatMu.Unlock()
|
||||
|
||||
ag.chatCond.Broadcast()
|
||||
ag.chatSignalMu.Lock()
|
||||
close(ag.chatSignalCh)
|
||||
|
|
@ -55,14 +52,14 @@ func (ag *Agent) AppendChat(data []byte) {
|
|||
ag.chatSignalMu.Unlock()
|
||||
}
|
||||
|
||||
// EnsureTrailingNewline appends a newline if the log doesn't end with one.
|
||||
func (ag *Agent) EnsureTrailingNewline() {
|
||||
ag.chatMu.Lock()
|
||||
if len(ag.chatLog) > 0 && ag.chatLog[len(ag.chatLog)-1] != '\n' {
|
||||
ag.chatLog = append(ag.chatLog, '\n')
|
||||
func appendWithOverflow(log, data []byte, max int, start *int) []byte {
|
||||
log = append(log, data...)
|
||||
if len(log) > max {
|
||||
drop := len(log) - max
|
||||
log = append([]byte(nil), log[drop:]...)
|
||||
*start += drop
|
||||
}
|
||||
ag.chatVers++
|
||||
ag.chatMu.Unlock()
|
||||
return log
|
||||
}
|
||||
|
||||
// ChatMu returns the chat log mutex for external locking (streaming).
|
||||
|
|
@ -79,72 +76,71 @@ func (ag *Agent) ChatSignal() <-chan struct{} {
|
|||
return ch
|
||||
}
|
||||
|
||||
// ChatLog returns the raw chat log bytes (caller must hold ChatMu.RLock).
|
||||
func (ag *Agent) ChatLog() []byte { return ag.chatLog }
|
||||
// RawLog returns the JSONL log bytes (caller must hold ChatMu.RLock).
|
||||
func (ag *Agent) RawLog() []byte { return ag.rawLog }
|
||||
|
||||
// ChatRead returns new chat data since the given offset (base).
|
||||
// Returns (data, nextBase, error). If no new data, returns empty data.
|
||||
func (ag *Agent) ChatRead(base string) ([]byte, string, error) {
|
||||
// TextLog returns the rendered text log bytes (caller must hold ChatMu.RLock).
|
||||
func (ag *Agent) TextLog() []byte { return ag.textLog }
|
||||
|
||||
// RawLogRead returns new JSONL data since the given offset.
|
||||
func (ag *Agent) RawLogRead(base string) ([]byte, string, error) {
|
||||
return ag.logRead(base, &ag.rawLog, &ag.rawStart)
|
||||
}
|
||||
|
||||
// TextLogRead returns new rendered text since the given offset.
|
||||
func (ag *Agent) TextLogRead(base string) ([]byte, string, error) {
|
||||
return ag.logRead(base, &ag.textLog, &ag.textStart)
|
||||
}
|
||||
|
||||
func (ag *Agent) logRead(base string, log *[]byte, start *int) ([]byte, string, error) {
|
||||
var offset int
|
||||
if base != "" {
|
||||
fmt.Sscanf(base, "%d", &offset)
|
||||
} else {
|
||||
// Empty base = start from beginning (for replay)
|
||||
ag.chatMu.RLock()
|
||||
offset = len(ag.chatLog) + ag.chatStart
|
||||
offset = *start
|
||||
ag.chatMu.RUnlock()
|
||||
}
|
||||
ag.chatMu.RLock()
|
||||
if offset < ag.chatStart {
|
||||
offset = ag.chatStart
|
||||
if offset < *start {
|
||||
offset = *start
|
||||
}
|
||||
localOffset := offset - ag.chatStart
|
||||
log := ag.chatLog
|
||||
if len(log) <= localOffset {
|
||||
localOffset := offset - *start
|
||||
if len(*log) <= localOffset {
|
||||
ag.chatMu.RUnlock()
|
||||
return nil, fmt.Sprintf("%d", offset), nil
|
||||
}
|
||||
data := make([]byte, len(log)-localOffset)
|
||||
copy(data, log[localOffset:])
|
||||
newOffset := ag.chatStart + len(log)
|
||||
data := make([]byte, len(*log)-localOffset)
|
||||
copy(data, (*log)[localOffset:])
|
||||
newOffset := *start + len(*log)
|
||||
ag.chatMu.RUnlock()
|
||||
return data, fmt.Sprintf("%d", newOffset), nil
|
||||
}
|
||||
|
||||
// ChatSearchByID returns the block content for the given block ID.
|
||||
// Returns the block text (without markers) and true if found, empty and false otherwise.
|
||||
func (ag *Agent) ChatSearchByID(blockID string) ([]byte, bool) {
|
||||
// BlockByID returns the block with the given ID from the raw log.
|
||||
func (ag *Agent) BlockByID(blockID string) (format.Block, bool) {
|
||||
if blockID == "" {
|
||||
return nil, false
|
||||
return format.Block{}, false
|
||||
}
|
||||
ag.chatMu.RLock()
|
||||
log := ag.chatLog
|
||||
raw := ag.rawLog
|
||||
ag.chatMu.RUnlock()
|
||||
|
||||
// Parse blocks looking for the target ID
|
||||
// Format: [[[role:name#id]]] ... [[[end]]]
|
||||
target := "#" + blockID + "]]]"
|
||||
idx := bytes.Index(log, []byte(target))
|
||||
if idx < 0 {
|
||||
return nil, false
|
||||
// Scan JSONL lines for matching ID
|
||||
for _, line := range bytes.Split(raw, []byte("\n")) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
b, err := format.UnmarshalBlock(line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if b.ID == blockID && !b.Partial {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
|
||||
// Find the start of this block header
|
||||
headerStart := bytes.LastIndex(log[:idx], []byte("[[["))
|
||||
if headerStart < 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Find [[[end]]] after the header
|
||||
endMarker := []byte("[[[end]]]")
|
||||
endIdx := bytes.Index(log[idx:], endMarker)
|
||||
if endIdx < 0 {
|
||||
return nil, false
|
||||
}
|
||||
blockEnd := idx + endIdx + len(endMarker)
|
||||
|
||||
// Extract block including header and end marker
|
||||
block := log[headerStart:blockEnd]
|
||||
return block, true
|
||||
return format.Block{}, false
|
||||
}
|
||||
|
||||
// --- Plan methods ---
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"ollie/format"
|
||||
)
|
||||
|
||||
// testAgent creates a minimal Agent for chat/block testing.
|
||||
|
|
@ -81,192 +82,94 @@ func TestNextBlockID_Length(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatSearchByID_Found(t *testing.T) {
|
||||
func TestAppendBlock_JSONL(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
// Simulate chat log with blocks
|
||||
chatData := `[[[user#abc12345]]]
|
||||
Hello
|
||||
[[[end]]]
|
||||
[[[assistant#def67890]]]
|
||||
Hi there
|
||||
[[[end]]]
|
||||
[[[tool:shell#11223344]]]
|
||||
` + "```" + `
|
||||
output here
|
||||
` + "```" + `
|
||||
[[[end]]]
|
||||
`
|
||||
ag.AppendChat([]byte(chatData))
|
||||
ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"})
|
||||
ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"})
|
||||
ag.AppendBlock(format.Block{Role: "tool", ID: "11223344", Content: "output"})
|
||||
|
||||
tests := []struct {
|
||||
blockID string
|
||||
wantFound bool
|
||||
wantPart string
|
||||
}{
|
||||
{"abc12345", true, "[[[user#abc12345]]]"},
|
||||
{"def67890", true, "[[[assistant#def67890]]]"},
|
||||
{"11223344", true, "[[[tool:shell#11223344]]]"},
|
||||
{"notfound", false, ""},
|
||||
{"", false, ""},
|
||||
ag.ChatMu().RLock()
|
||||
raw := ag.RawLog()
|
||||
ag.ChatMu().RUnlock()
|
||||
|
||||
// Should contain valid JSONL
|
||||
if len(raw) == 0 {
|
||||
t.Fatal("raw log should not be empty")
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
block, found := ag.ChatSearchByID(tt.blockID)
|
||||
if found != tt.wantFound {
|
||||
t.Errorf("ChatSearchByID(%q): got found=%v, want %v", tt.blockID, found, tt.wantFound)
|
||||
// Parse and verify
|
||||
lines := 0
|
||||
for _, line := range splitLines(raw) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if found && !strings.Contains(string(block), tt.wantPart) {
|
||||
t.Errorf("ChatSearchByID(%q): block missing %q, got:\n%s", tt.blockID, tt.wantPart, block)
|
||||
lines++
|
||||
b, err := format.UnmarshalBlock(line)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse JSONL line: %v", err)
|
||||
}
|
||||
if b.ID == "" {
|
||||
t.Error("block should have ID")
|
||||
}
|
||||
}
|
||||
if lines != 3 {
|
||||
t.Errorf("expected 3 blocks, got %d", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearchByID_ReturnsFullBlock(t *testing.T) {
|
||||
func TestBlockByID(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
chatData := `[[[user#aabbccdd]]]
|
||||
test content
|
||||
more lines
|
||||
[[[end]]]
|
||||
`
|
||||
ag.AppendChat([]byte(chatData))
|
||||
ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"})
|
||||
ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"})
|
||||
|
||||
block, found := ag.ChatSearchByID("aabbccdd")
|
||||
// Find existing block
|
||||
block, found := ag.BlockByID("def67890")
|
||||
if !found {
|
||||
t.Fatal("block should be found")
|
||||
}
|
||||
|
||||
blockStr := string(block)
|
||||
if !strings.HasPrefix(blockStr, "[[[user#aabbccdd]]]") {
|
||||
t.Errorf("block should start with header, got: %s", blockStr)
|
||||
if block.Role != "assistant" || block.Content != "Hi there" {
|
||||
t.Errorf("wrong block content: %+v", block)
|
||||
}
|
||||
if !strings.HasSuffix(blockStr, "[[[end]]]") {
|
||||
t.Errorf("block should end with end marker, got: %s", blockStr)
|
||||
}
|
||||
if !strings.Contains(blockStr, "test content") {
|
||||
t.Errorf("block should contain content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearchByID_PartialMatch(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
// Block ID is full 8 chars
|
||||
chatData := `[[[user#12345678]]]
|
||||
content
|
||||
[[[end]]]
|
||||
`
|
||||
ag.AppendChat([]byte(chatData))
|
||||
|
||||
// Should not find partial match
|
||||
_, found := ag.ChatSearchByID("1234")
|
||||
// Not found
|
||||
_, found = ag.BlockByID("notfound")
|
||||
if found {
|
||||
t.Error("partial block ID should not match")
|
||||
t.Error("nonexistent block should not be found")
|
||||
}
|
||||
|
||||
// Should find exact match
|
||||
_, found = ag.ChatSearchByID("12345678")
|
||||
if !found {
|
||||
t.Error("exact block ID should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearchByID_MultipleBlocks(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
// Multiple blocks with different IDs
|
||||
chatData := `[[[user#aaaaaaaa]]]
|
||||
first
|
||||
[[[end]]]
|
||||
[[[assistant#bbbbbbbb]]]
|
||||
second
|
||||
[[[end]]]
|
||||
[[[user#cccccccc]]]
|
||||
third
|
||||
[[[end]]]
|
||||
`
|
||||
ag.AppendChat([]byte(chatData))
|
||||
|
||||
// Search for middle block
|
||||
block, found := ag.ChatSearchByID("bbbbbbbb")
|
||||
if !found {
|
||||
t.Fatal("middle block should be found")
|
||||
}
|
||||
|
||||
blockStr := string(block)
|
||||
if !strings.Contains(blockStr, "[[[assistant#bbbbbbbb]]]") {
|
||||
t.Errorf("should find correct block, got: %s", blockStr)
|
||||
}
|
||||
if strings.Contains(blockStr, "first") || strings.Contains(blockStr, "third") {
|
||||
t.Errorf("should not include content from other blocks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearchByID_NoEndMarker(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
// Block without end marker (incomplete/streaming)
|
||||
chatData := `[[[user#12345678]]]
|
||||
content
|
||||
`
|
||||
ag.AppendChat([]byte(chatData))
|
||||
|
||||
// Should not find block without end marker
|
||||
_, found := ag.ChatSearchByID("12345678")
|
||||
// Empty ID
|
||||
_, found = ag.BlockByID("")
|
||||
if found {
|
||||
t.Error("block without end marker should not be found")
|
||||
t.Error("empty block ID should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearchByID_NamedBlock(t *testing.T) {
|
||||
func TestBlockByID_SkipsPartial(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
// Block with name component (role:name#id format)
|
||||
chatData := `[[[tool:file_read#abcd1234]]]
|
||||
` + "```" + `
|
||||
file contents
|
||||
` + "```" + `
|
||||
[[[end]]]
|
||||
`
|
||||
ag.AppendChat([]byte(chatData))
|
||||
// Add partial block first
|
||||
ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "H", Partial: true})
|
||||
// Then final
|
||||
ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "Hello"})
|
||||
|
||||
block, found := ag.ChatSearchByID("abcd1234")
|
||||
block, found := ag.BlockByID("abc12345")
|
||||
if !found {
|
||||
t.Fatal("named block should be found")
|
||||
t.Fatal("block should be found")
|
||||
}
|
||||
if !strings.Contains(string(block), "tool:file_read") {
|
||||
t.Errorf("block should include role:name, got: %s", block)
|
||||
if block.Content != "Hello" {
|
||||
t.Errorf("should find final block, not partial: %+v", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearchByID_ResponseIDBlock(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
// Assistant block with response ID (assistant:responseID#blockID format)
|
||||
chatData := `[[[assistant:resp_123#aabbccdd]]]
|
||||
response content
|
||||
[[[end]]]
|
||||
`
|
||||
ag.AppendChat([]byte(chatData))
|
||||
|
||||
block, found := ag.ChatSearchByID("aabbccdd")
|
||||
if !found {
|
||||
t.Fatal("assistant block with response ID should be found")
|
||||
}
|
||||
if !strings.Contains(string(block), "assistant:resp_123#aabbccdd") {
|
||||
t.Errorf("block should include response ID, got: %s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendChat_SignalsBroadcast(t *testing.T) {
|
||||
func TestAppendBlock_SignalsBroadcast(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
signalCh := ag.ChatSignal()
|
||||
|
||||
// Append data
|
||||
ag.AppendChat([]byte("test"))
|
||||
// Append block
|
||||
ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hi"})
|
||||
|
||||
// Signal channel should be closed
|
||||
select {
|
||||
|
|
@ -286,36 +189,99 @@ func TestAppendChat_SignalsBroadcast(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatRead_Streaming(t *testing.T) {
|
||||
func TestRawLogRead_Streaming(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
// Initial read with no base - should return empty and current offset
|
||||
data, base, err := ag.ChatRead("")
|
||||
data, base, err := ag.RawLogRead("")
|
||||
if err != nil {
|
||||
t.Fatalf("ChatRead error: %v", err)
|
||||
t.Fatalf("RawLogRead error: %v", err)
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Errorf("initial read should be empty, got: %s", data)
|
||||
}
|
||||
|
||||
// Append some data
|
||||
ag.AppendChat([]byte("hello"))
|
||||
// Append a block
|
||||
ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hello"})
|
||||
|
||||
// Read with previous base - should get new data
|
||||
data, newBase, err := ag.ChatRead(base)
|
||||
data, newBase, err := ag.RawLogRead(base)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatRead error: %v", err)
|
||||
t.Fatalf("RawLogRead error: %v", err)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Errorf("expected 'hello', got: %s", data)
|
||||
if len(data) == 0 {
|
||||
t.Error("should have new data")
|
||||
}
|
||||
|
||||
// Verify it's valid JSON
|
||||
b, err := format.UnmarshalBlock(data[:len(data)-1]) // strip trailing newline
|
||||
if err != nil {
|
||||
t.Errorf("data should be valid JSONL: %v", err)
|
||||
}
|
||||
if b.Content != "hello" {
|
||||
t.Errorf("wrong content: %s", b.Content)
|
||||
}
|
||||
|
||||
// Read again with new base - should be empty
|
||||
data, _, err = ag.ChatRead(newBase)
|
||||
data, _, err = ag.RawLogRead(newBase)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatRead error: %v", err)
|
||||
t.Fatalf("RawLogRead error: %v", err)
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Errorf("no new data, should be empty, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextLog_RendersHumanReadable(t *testing.T) {
|
||||
ag := testAgent("session1", "agent1")
|
||||
|
||||
ag.AppendBlock(format.Block{Role: "user", ID: "a", Content: "Hello"})
|
||||
ag.AppendBlock(format.Block{Role: "assistant", ID: "b", Content: "Hi there"})
|
||||
ag.AppendBlock(format.Block{Role: "call", ID: "c", Name: "shell", Content: `{"cmd":"ls"}`})
|
||||
|
||||
ag.ChatMu().RLock()
|
||||
text := string(ag.TextLog())
|
||||
ag.ChatMu().RUnlock()
|
||||
|
||||
// Should have user and assistant content
|
||||
if !contains(text, "Hello") {
|
||||
t.Error("should contain user message")
|
||||
}
|
||||
if !contains(text, "Hi there") {
|
||||
t.Error("should contain assistant message")
|
||||
}
|
||||
// Should NOT have call content (hidden from rendered log)
|
||||
if contains(text, "shell") || contains(text, "cmd") {
|
||||
t.Error("call content should be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to split JSONL
|
||||
func splitLines(data []byte) [][]byte {
|
||||
var lines [][]byte
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == '\n' {
|
||||
lines = append(lines, data[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(data) {
|
||||
lines = append(lines, data[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,138 +1,158 @@
|
|||
// chatlog.go — Chat output formatting and event handling.
|
||||
//
|
||||
// Converts agent events (streaming text, tool calls, tool results) into
|
||||
// formatted output written to the chat log. The output handler is set up
|
||||
// once at agent construction.
|
||||
// JSONL blocks written to the chat log. Handles streaming by emitting
|
||||
// partial blocks that are replaced by final blocks.
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ollie/cmd/olliesrv/internal/backend"
|
||||
"ollie/format"
|
||||
)
|
||||
|
||||
// initChatHandler sets up the agent's output handler to write to its own chat log.
|
||||
// Called once at construction.
|
||||
func (ag *Agent) initChatHandler() {
|
||||
var streamingRole string
|
||||
var streamingID string
|
||||
var streamingContent strings.Builder
|
||||
var streamingResponseID string
|
||||
var streamingFence string
|
||||
var streamingBlockID string
|
||||
var streamingFormat string
|
||||
|
||||
// flushPartial emits a partial block with current content.
|
||||
flushPartial := func() {
|
||||
if streamingID == "" {
|
||||
return
|
||||
}
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: streamingRole,
|
||||
ID: streamingID,
|
||||
Content: streamingContent.String(),
|
||||
Format: streamingFormat,
|
||||
Partial: true,
|
||||
})
|
||||
}
|
||||
|
||||
// closeBlock emits the final block and resets streaming state.
|
||||
closeBlock := func() {
|
||||
if streamingID == "" {
|
||||
return
|
||||
}
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: streamingRole,
|
||||
ID: streamingID,
|
||||
Content: streamingContent.String(),
|
||||
Format: streamingFormat,
|
||||
Partial: false,
|
||||
})
|
||||
streamingRole = ""
|
||||
streamingID = ""
|
||||
streamingContent.Reset()
|
||||
streamingFormat = ""
|
||||
}
|
||||
|
||||
ag.output = func(ev Event) {
|
||||
switch ev.Role {
|
||||
case "assistant":
|
||||
if streamingRole != ev.Role || (ev.Role == "assistant" && ev.ResponseID != streamingResponseID) {
|
||||
if streamingRole != "" {
|
||||
if streamingFence != "" {
|
||||
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
|
||||
streamingFence = ""
|
||||
}
|
||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||
}
|
||||
blockID := ag.NextBlockID()
|
||||
if ev.Role == "assistant" && ev.ResponseID != "" {
|
||||
// Include both responseID and blockID: [[[assistant:responseID#blockID]]]
|
||||
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", ev.ResponseID, blockID)))
|
||||
streamingResponseID = ev.ResponseID
|
||||
} else {
|
||||
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID)))
|
||||
// New response or different response ID = new block
|
||||
if streamingRole != ev.Role || ev.ResponseID != streamingResponseID {
|
||||
if streamingID != "" {
|
||||
closeBlock()
|
||||
}
|
||||
streamingRole = ev.Role
|
||||
streamingBlockID = blockID
|
||||
streamingID = ag.NextBlockID()
|
||||
streamingResponseID = ev.ResponseID
|
||||
streamingContent.Reset()
|
||||
}
|
||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
|
||||
streamingContent.WriteString(ev.Content)
|
||||
flushPartial()
|
||||
|
||||
case "reasoning":
|
||||
if streamingRole != ev.Role {
|
||||
if streamingRole != "" {
|
||||
if streamingFence != "" {
|
||||
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
|
||||
streamingFence = ""
|
||||
}
|
||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||
if streamingID != "" {
|
||||
closeBlock()
|
||||
}
|
||||
blockID := ag.NextBlockID()
|
||||
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n", blockID)))
|
||||
streamingRole = ev.Role
|
||||
streamingBlockID = blockID
|
||||
streamingID = ag.NextBlockID()
|
||||
streamingContent.Reset()
|
||||
}
|
||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
|
||||
streamingContent.WriteString(ev.Content)
|
||||
flushPartial()
|
||||
|
||||
case "tool":
|
||||
if streamingRole == "tool" {
|
||||
if ev.Content == "" {
|
||||
if streamingFence != "" {
|
||||
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
|
||||
streamingFence = ""
|
||||
}
|
||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||
streamingRole = ""
|
||||
// Empty content = end of streaming tool output
|
||||
closeBlock()
|
||||
} else {
|
||||
ag.AppendChat([]byte(ev.Content))
|
||||
// Append to streaming tool content
|
||||
streamingContent.WriteString(ev.Content)
|
||||
flushPartial()
|
||||
}
|
||||
return
|
||||
}
|
||||
if streamingRole != "" {
|
||||
if streamingFence != "" {
|
||||
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
|
||||
streamingFence = ""
|
||||
}
|
||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||
streamingRole = ""
|
||||
if streamingID != "" {
|
||||
closeBlock()
|
||||
}
|
||||
if ev.Content == "" {
|
||||
// Streaming tool output: write header with blockID
|
||||
blockID := ag.NextBlockID()
|
||||
ag.AppendChat([]byte(fmt.Sprintf("[[[tool:%s#%s]]]\n```%s\n", ev.Name, blockID, ev.OutputFormat)))
|
||||
streamingFence = format.ToolFence
|
||||
// Start streaming tool output
|
||||
streamingRole = "tool"
|
||||
streamingBlockID = blockID
|
||||
streamingID = ag.NextBlockID()
|
||||
streamingFormat = ev.OutputFormat
|
||||
streamingContent.Reset()
|
||||
} else {
|
||||
// Complete tool output in one event
|
||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: "tool",
|
||||
ID: ag.NextBlockID(),
|
||||
Content: ev.Content,
|
||||
Format: ev.OutputFormat,
|
||||
})
|
||||
}
|
||||
|
||||
case "retry":
|
||||
if streamingRole == "retry" {
|
||||
ag.AppendChat([]byte("\n" + ev.Content))
|
||||
streamingContent.WriteString("\n" + ev.Content)
|
||||
flushPartial()
|
||||
return
|
||||
}
|
||||
if streamingRole != "" {
|
||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||
streamingRole = ""
|
||||
if streamingID != "" {
|
||||
closeBlock()
|
||||
}
|
||||
blockID := ag.NextBlockID()
|
||||
ag.AppendChat([]byte(fmt.Sprintf("[[[retry#%s]]]\n", blockID)))
|
||||
streamingRole = "retry"
|
||||
streamingBlockID = blockID
|
||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
|
||||
streamingID = ag.NextBlockID()
|
||||
streamingContent.Reset()
|
||||
streamingContent.WriteString(ev.Content)
|
||||
flushPartial()
|
||||
|
||||
case "call":
|
||||
if streamingRole != "" {
|
||||
if streamingFence != "" {
|
||||
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
|
||||
streamingFence = ""
|
||||
}
|
||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||
streamingRole = ""
|
||||
if streamingID != "" {
|
||||
closeBlock()
|
||||
}
|
||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: "call",
|
||||
ID: ag.NextBlockID(),
|
||||
Name: ev.Name,
|
||||
Content: ev.Content,
|
||||
})
|
||||
|
||||
case "state", "usage", "limitretry":
|
||||
// Internal events for status/stats, not chat display — skip
|
||||
return
|
||||
|
||||
default:
|
||||
if streamingRole != "" {
|
||||
if streamingFence != "" {
|
||||
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
|
||||
streamingFence = ""
|
||||
}
|
||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||
streamingRole = ""
|
||||
// error, info, stalled, etc.
|
||||
if streamingID != "" {
|
||||
closeBlock()
|
||||
}
|
||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: ev.Role,
|
||||
ID: ag.NextBlockID(),
|
||||
Content: ev.Content,
|
||||
})
|
||||
}
|
||||
_ = streamingBlockID // suppress unused warning (available for future use)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,45 +167,43 @@ func (ag *Agent) ReplayMessages(messages []backend.Message) {
|
|||
start++
|
||||
}
|
||||
|
||||
// Build a lookup from ToolCallID → tool name.
|
||||
callNames := make(map[string]string)
|
||||
for _, m := range messages[start:] {
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.ID != "" {
|
||||
callNames[tc.ID] = tc.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range messages[start:] {
|
||||
switch m.Role {
|
||||
case "system":
|
||||
// Skip
|
||||
case "user":
|
||||
ag.AppendChat(format.FormatEvent("user", "", m.Content, "", ag.NextBlockID()))
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: "user",
|
||||
ID: ag.NextBlockID(),
|
||||
Content: m.Content,
|
||||
})
|
||||
case "assistant":
|
||||
if m.Reasoning != "" {
|
||||
blockID := ag.NextBlockID()
|
||||
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n%s\n%s\n", blockID, m.Reasoning, format.EndTag)))
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: "reasoning",
|
||||
ID: ag.NextBlockID(),
|
||||
Content: m.Reasoning,
|
||||
})
|
||||
}
|
||||
blockID := ag.NextBlockID()
|
||||
if m.ID != "" {
|
||||
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", m.ID, blockID)))
|
||||
} else {
|
||||
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID)))
|
||||
}
|
||||
if m.Content != "" {
|
||||
ag.AppendChat([]byte(m.Content + "\n"))
|
||||
}
|
||||
ag.AppendChat([]byte(format.EndTag + "\n"))
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: "assistant",
|
||||
ID: ag.NextBlockID(),
|
||||
Content: m.Content,
|
||||
})
|
||||
for _, tc := range m.ToolCalls {
|
||||
ag.AppendChat(format.FormatEvent("call", tc.Name, string(tc.Arguments), "", ag.NextBlockID()))
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: "call",
|
||||
ID: ag.NextBlockID(),
|
||||
Name: tc.Name,
|
||||
Content: string(tc.Arguments),
|
||||
})
|
||||
}
|
||||
case "tool":
|
||||
name := callNames[m.ToolCallID]
|
||||
if name == "" {
|
||||
name = m.ToolCallID
|
||||
}
|
||||
ag.AppendChat(format.FormatEvent("tool", name, m.Content, "", ag.NextBlockID()))
|
||||
ag.AppendBlock(format.Block{
|
||||
Role: "tool",
|
||||
ID: ag.NextBlockID(),
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,11 +147,16 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
contextParts = append(contextParts, toolHints)
|
||||
}
|
||||
}
|
||||
if len(contextParts) > 0 {
|
||||
input = "<context>\n" + strings.Join(contextParts, "\n") + "\n</context>\n\n" + input
|
||||
}
|
||||
|
||||
ag.emit(Event{Role: "user", Content: input})
|
||||
// Emit context as separate block (filtered by GUI), then user message.
|
||||
// History still gets the combined input for LLM context.
|
||||
userInput := input
|
||||
if len(contextParts) > 0 {
|
||||
contextBlock := strings.Join(contextParts, "\n")
|
||||
ag.emit(Event{Role: "context", Content: contextBlock})
|
||||
input = "<context>\n" + contextBlock + "\n</context>\n\n" + userInput
|
||||
}
|
||||
ag.emit(Event{Role: "user", Content: userInput})
|
||||
|
||||
// Snapshot messages before this turn modifies them. Restored on context
|
||||
// overflow so the retry starts from a clean state.
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
# Requires: running olliesrv, existing agent with chat history
|
||||
#
|
||||
# This test verifies:
|
||||
# 1. Block IDs can be extracted from chat log
|
||||
# 2. chat.search finds blocks by exact ID
|
||||
# 1. Block IDs can be extracted from log.raw (JSONL)
|
||||
# 2. block file returns JSON for exact ID
|
||||
# 3. Partial IDs are rejected
|
||||
# 4. Non-existent IDs return proper error
|
||||
# 5. Different block types (user, assistant, call, tool, error) work
|
||||
# 5. Different block types (user, assistant, call, tool) work
|
||||
|
||||
set -e
|
||||
|
||||
|
|
@ -16,25 +16,26 @@ AGENT="src:ollie"
|
|||
|
||||
echo "=== Block ID E2E Test ==="
|
||||
|
||||
# Get some block IDs from the log
|
||||
echo "Extracting block IDs from log..."
|
||||
BLOCK_IDS=$(o read session/$SESSION/agent/$AGENT/log 2>&1 | strings | grep -oE '\[\[\[[a-z]+[:#][^]]+#([a-f0-9]{8})\]\]\]' | sed 's/.*#\([a-f0-9]\{8\}\)\]\]\]/\1/' | tail -5)
|
||||
# Get some block IDs from the JSONL log
|
||||
echo "Extracting block IDs from log.raw..."
|
||||
BLOCK_IDS=$(o read session/$SESSION/agent/$AGENT/log.raw 2>&1 | grep -oE '"id":"[a-f0-9]{8}"' | sed 's/"id":"//;s/"//' | tail -5)
|
||||
|
||||
if [ -z "$BLOCK_IDS" ]; then
|
||||
echo "FAIL: No block IDs found in log"
|
||||
echo "FAIL: No block IDs found in log.raw"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found block IDs: $(echo $BLOCK_IDS | tr '\n' ' ')"
|
||||
|
||||
# Test 1: Search for existing blocks
|
||||
# Test 1: Lookup existing blocks
|
||||
echo ""
|
||||
echo "Test 1: Search for existing blocks..."
|
||||
echo "Test 1: Lookup existing blocks via block file..."
|
||||
for bid in $BLOCK_IDS; do
|
||||
result=$(o rdwr session/$SESSION/agent/$AGENT/chat.search "$bid" 2>&1)
|
||||
if echo "$result" | grep -q "\[\[\[.*#$bid\]\]\]"; then
|
||||
result=$(o rdwr session/$SESSION/agent/$AGENT/block "$bid" 2>&1)
|
||||
if echo "$result" | grep -q '"id":"'$bid'"'; then
|
||||
echo " PASS: Found block $bid"
|
||||
else
|
||||
echo " FAIL: Block $bid not found or malformed"
|
||||
echo " Got: $result"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
|
@ -44,7 +45,7 @@ echo ""
|
|||
echo "Test 2: Partial ID rejection..."
|
||||
FIRST_ID=$(echo "$BLOCK_IDS" | head -1)
|
||||
PARTIAL=${FIRST_ID:0:4}
|
||||
if o rdwr session/$SESSION/agent/$AGENT/chat.search "$PARTIAL" 2>&1 | grep -q "block not found"; then
|
||||
if o rdwr session/$SESSION/agent/$AGENT/block "$PARTIAL" 2>&1 | grep -q "block not found"; then
|
||||
echo " PASS: Partial ID '$PARTIAL' correctly rejected"
|
||||
else
|
||||
echo " FAIL: Partial ID '$PARTIAL' should be rejected"
|
||||
|
|
@ -54,22 +55,22 @@ fi
|
|||
# Test 3: Non-existent ID
|
||||
echo ""
|
||||
echo "Test 3: Non-existent ID..."
|
||||
if o rdwr session/$SESSION/agent/$AGENT/chat.search "deadbeef" 2>&1 | grep -q "block not found"; then
|
||||
if o rdwr session/$SESSION/agent/$AGENT/block "deadbeef" 2>&1 | grep -q "block not found"; then
|
||||
echo " PASS: Non-existent ID 'deadbeef' correctly rejected"
|
||||
else
|
||||
echo " FAIL: Non-existent ID should return error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 4: Block content includes header and end marker
|
||||
# Test 4: Block JSON structure validation
|
||||
echo ""
|
||||
echo "Test 4: Block structure validation..."
|
||||
echo "Test 4: Block JSON structure validation..."
|
||||
FIRST_ID=$(echo "$BLOCK_IDS" | head -1)
|
||||
BLOCK=$(o rdwr session/$SESSION/agent/$AGENT/chat.search "$FIRST_ID" 2>&1)
|
||||
if echo "$BLOCK" | grep -q "^\[\[\[" && echo "$BLOCK" | grep -q "\[\[\[end\]\]\]$"; then
|
||||
echo " PASS: Block has proper header and end marker"
|
||||
BLOCK=$(o rdwr session/$SESSION/agent/$AGENT/block "$FIRST_ID" 2>&1)
|
||||
if echo "$BLOCK" | grep -qE '^\{"role":"[^"]+","id":"[a-f0-9]{8}","content":'; then
|
||||
echo " PASS: Block has proper JSON structure with role, id, content"
|
||||
else
|
||||
echo " FAIL: Block missing header or end marker"
|
||||
echo " FAIL: Block missing required JSON fields"
|
||||
echo " Got: $BLOCK"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"ollie/cmd/olliesrv/internal/backend"
|
||||
"ollie/cmd/olliesrv/internal/metrics"
|
||||
"ollie/cmd/olliesrv/internal/session"
|
||||
"ollie/format"
|
||||
"ollie/util"
|
||||
"ollie/virtfs"
|
||||
)
|
||||
|
|
@ -687,15 +688,14 @@ func buildSessionChildren(
|
|||
defer cancel()
|
||||
|
||||
session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".new", "")
|
||||
subPrompt := "You are a sub-agent. Your task is below. " +
|
||||
"Execute it completely using tools — investigate, implement, verify. " +
|
||||
"Do NOT respond with a plan or intentions. Do NOT say what you will do. " +
|
||||
"Call tools. Do the work. Your final text response must summarize what you ACCOMPLISHED.\n\n" + req.Prompt
|
||||
ag.Submit(subCtx, subPrompt)
|
||||
ag.EnsureTrailingNewline()
|
||||
reply := ag.Reply()
|
||||
s.RemoveAgent(ag.ID())
|
||||
session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "")
|
||||
subPrompt := "You are a sub-agent. Your task is below. " +
|
||||
"Execute it completely using tools — investigate, implement, verify. " +
|
||||
"Do NOT respond with a plan or intentions. Do NOT say what you will do. " +
|
||||
"Call tools. Do the work. Your final text response must summarize what you ACCOMPLISHED.\n\n" + req.Prompt
|
||||
ag.Submit(subCtx, subPrompt)
|
||||
reply := ag.Reply()
|
||||
s.RemoveAgent(ag.ID())
|
||||
session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "")
|
||||
|
||||
if reply == "" {
|
||||
return []byte("(no reply)\n"), nil
|
||||
|
|
@ -767,7 +767,6 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
|
|||
} else {
|
||||
startAsync(s.Ctx, func() {
|
||||
a.Submit(s.Ctx, text)
|
||||
a.EnsureTrailingNewline()
|
||||
})
|
||||
}
|
||||
return []byte("ok\n"), nil
|
||||
|
|
@ -786,7 +785,6 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
|
|||
}
|
||||
startAsync(s.Ctx, func() {
|
||||
a.Submit(s.Ctx, input)
|
||||
a.EnsureTrailingNewline()
|
||||
})
|
||||
return nil
|
||||
}),
|
||||
|
|
@ -798,106 +796,90 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
|
|||
return nil
|
||||
}
|
||||
if err := a.Queue(input); err != nil {
|
||||
return err
|
||||
}
|
||||
// If the agent is idle, trigger processing.
|
||||
if !a.IsRunning() {
|
||||
if !startAsync(s.Ctx, func() {
|
||||
if next, ok := a.PopQueue(); ok {
|
||||
a.Submit(s.Ctx, next)
|
||||
a.EnsureTrailingNewline()
|
||||
}
|
||||
}) {
|
||||
return fmt.Errorf("async work limit reached")
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
item, ok := a.PopQueue()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(item), nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("chat", 0440, // read-only, owner + group
|
||||
virtfs.StatOverride(chatStat),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
a.ChatMu().RLock()
|
||||
defer a.ChatMu().RUnlock()
|
||||
return stripMarkers(a.ChatLog()), nil
|
||||
}),
|
||||
virtfs.Stream(func(base string) ([]byte, string, error) {
|
||||
data, nextBase, err := a.ChatRead(base)
|
||||
if err != nil || len(data) == 0 {
|
||||
return data, nextBase, err
|
||||
}
|
||||
stripped := stripMarkers(data)
|
||||
if len(stripped) > 0 {
|
||||
return stripped, nextBase, nil
|
||||
}
|
||||
return nil, nextBase, nil
|
||||
}, a.ChatSignal),
|
||||
),
|
||||
virtfs.FileNode("chat.raw", 0440, // read-only, owner + group
|
||||
virtfs.StatOverride(chatStat),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
a.ChatMu().RLock()
|
||||
defer a.ChatMu().RUnlock()
|
||||
log := a.ChatLog()
|
||||
data := make([]byte, len(log))
|
||||
copy(data, log)
|
||||
return data, nil
|
||||
}),
|
||||
virtfs.Stream(a.ChatRead, a.ChatSignal),
|
||||
// If the agent is idle, trigger processing.
|
||||
if !a.IsRunning() {
|
||||
if !startAsync(s.Ctx, func() {
|
||||
if next, ok := a.PopQueue(); ok {
|
||||
a.Submit(s.Ctx, next)
|
||||
}
|
||||
}) {
|
||||
return fmt.Errorf("async work limit reached")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
item, ok := a.PopQueue()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(item), nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("chat.search", 0660, // rdwr - write blockId, read block
|
||||
virtfs.Doc("Search chat by block ID. Write ID, read block content."),
|
||||
// log.raw — JSONL (one Block per line), for GUI/programmatic access
|
||||
virtfs.FileNode("log.raw", 0440,
|
||||
virtfs.Doc("JSONL chat log. One JSON block per line."),
|
||||
virtfs.StatOverride(chatStat),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
a.ChatMu().RLock()
|
||||
defer a.ChatMu().RUnlock()
|
||||
raw := a.RawLog()
|
||||
data := make([]byte, len(raw))
|
||||
copy(data, raw)
|
||||
return data, nil
|
||||
}),
|
||||
),
|
||||
// log — Rendered plain text for TUI/humans (non-blocking read)
|
||||
virtfs.FileNode("log", 0440,
|
||||
virtfs.Doc("Rendered chat log (plain text). Last 64KB."),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
const maxWindow = 64 * 1024
|
||||
a.ChatMu().RLock()
|
||||
text := a.TextLog()
|
||||
start := 0
|
||||
if len(text) > maxWindow {
|
||||
start = len(text) - maxWindow
|
||||
}
|
||||
data := make([]byte, len(text)-start)
|
||||
copy(data, text[start:])
|
||||
a.ChatMu().RUnlock()
|
||||
return data, nil
|
||||
}),
|
||||
),
|
||||
// block — Read block by ID (rdwr: write ID, read JSON block)
|
||||
virtfs.FileNode("block", 0660,
|
||||
virtfs.Doc("Get block by ID. Write block ID, read JSON block."),
|
||||
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
|
||||
blockID := strings.TrimSpace(string(data))
|
||||
block, found := a.ChatSearchByID(blockID)
|
||||
block, found := a.BlockByID(blockID)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("block not found: %s", blockID)
|
||||
}
|
||||
return block, nil
|
||||
return format.MarshalBlock(block), nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("state", 0444, // world-readable (non-sensitive)
|
||||
virtfs.Doc("Current agent state (idle, calling, thinking, paused)"),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
return []byte(a.State() + "\n"), nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("status", 0444,
|
||||
virtfs.Doc("Human-readable status: current activity and elapsed time"),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
return []byte(a.Status() + "\n"), nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("log", 0440, // read-only, owner + group
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
const maxWindow = 64 * 1024
|
||||
a.ChatMu().RLock()
|
||||
log := a.ChatLog()
|
||||
start := 0
|
||||
if len(log) > maxWindow {
|
||||
start = len(log) - maxWindow
|
||||
}
|
||||
data := make([]byte, len(log)-start)
|
||||
copy(data, log[start:])
|
||||
a.ChatMu().RUnlock()
|
||||
return data, nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("plan", 0660, // owner + group (frontend needs access)
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
return a.Plan(), nil
|
||||
}),
|
||||
virtfs.Write(func(data []byte) error {
|
||||
a.SetPlan(data)
|
||||
return nil
|
||||
}),
|
||||
virtfs.Doc("Current agent state (idle, calling, thinking, paused)"),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
return []byte(a.State() + "\n"), nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("status", 0444,
|
||||
virtfs.Doc("Human-readable status: current activity and elapsed time"),
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
return []byte(a.Status() + "\n"), nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("plan", 0660, // owner + group (frontend needs access)
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
return a.Plan(), nil
|
||||
}),
|
||||
virtfs.Write(func(data []byte) error {
|
||||
a.SetPlan(data)
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
virtfs.FileNode("cfg", 0640, // owner rw, group r
|
||||
virtfs.Read(func() ([]byte, error) {
|
||||
|
|
@ -965,21 +947,20 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
|
|||
return fmt.Errorf("peer %q not found", name)
|
||||
}
|
||||
input := strings.TrimSpace(string(data))
|
||||
if input == "" {
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
startAsync(s.Ctx, func() {
|
||||
target.Submit(s.Ctx, input)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
startAsync(s.Ctx, func() {
|
||||
target.Submit(s.Ctx, input)
|
||||
target.EnsureTrailingNewline()
|
||||
})
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
virtfs.FileNode("ctl", 0660, // owner + group (frontend needs access)
|
||||
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
virtfs.FileNode("ctl", 0660, // owner + group (frontend needs access)
|
||||
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
|
||||
return dispatch([]ctlCmd{
|
||||
{"kill", "destroy this agent", func(_ []string) ([]byte, error) {
|
||||
s.RemoveAgent(a.ID())
|
||||
|
|
|
|||
|
|
@ -205,8 +205,9 @@ Use `client_9p` for all 9P operations. Your session ID is `${OLLIE_SESSION_ID}`.
|
|||
| `plan` | r/w | Markdown checklist; survives compaction |
|
||||
| `prompt` | write | Submit a prompt to this agent |
|
||||
| `fifo` | r/w | Prompt queue. Write: enqueue. Read: dequeue. |
|
||||
| `chat` | read | Conversation (filtered text, streamable) |
|
||||
| `chat.raw` | read | Full conversation with block markers |
|
||||
| `log` | read | Conversation (filtered text, last 64KB) |
|
||||
| `log.raw` | read | Full conversation as JSONL stream |
|
||||
| `block` | rdwr | Lookup block by ID (write ID, read JSON) |
|
||||
| `state` | read | Current agent state (idle, calling, thinking, paused) |
|
||||
| `cfg` | r/w | Agent config (key=value: backend, model, cwd, temperature, etc.) |
|
||||
| `ctl` | rdwr | Control: stop, compact, clear, inject, agent, model, tools, tool_load, tool_unload, cwd, name, peeradd, peerdel, peers, proc |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# Then reload: cat $HOME/lib/plumbing | 9p write plumb/rules
|
||||
|
||||
# ollie:// URLs go to the ollie port
|
||||
# Format: ollie://session/agent#block
|
||||
# Format: ollie://session/agent#blockId (blockId is 8-char hex)
|
||||
type is text
|
||||
data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[0-9]+)?'
|
||||
data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[a-f0-9]+)?'
|
||||
plumb to ollie
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
# o myproj ls List session contents
|
||||
# o myproj/coding new Create session+agent (cwd=$PWD)
|
||||
# o myproj/coding prompt Interactive prompt
|
||||
# o myproj/coding read chat Read chat output
|
||||
# o myproj/coding read log Read text log output
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -512,13 +512,13 @@ cmd_tui() {
|
|||
|
||||
tmux kill-session -t "${tmux_session}" 2>/dev/null || true
|
||||
|
||||
# Layout: chat (top 80%), prompt (bottom 20%)
|
||||
# Layout: log (top 80%), prompt (bottom 20%)
|
||||
# Use slash separator - session names don't contain slashes, agent names might contain colons
|
||||
local ctx="$s/$a"
|
||||
tmux new-session -d -s "$tmux_session" -c "$HOME"
|
||||
tmux set-option -t "$tmux_session" mouse on
|
||||
sleep 0.3 # Wait for shell to initialize
|
||||
tmux send-keys -t "$tmux_session" "o '$ctx' log -n 1000; o '$ctx' read chat" Enter
|
||||
tmux send-keys -t "$tmux_session" "o '$ctx' log -n 1000; o '$ctx' read log.raw" Enter
|
||||
|
||||
tmux split-window -t "$tmux_session" -v -l 20%
|
||||
sleep 0.2
|
||||
|
|
@ -631,7 +631,7 @@ Commands (availability depends on context):
|
|||
bypass Show pending bypass request (session-level)
|
||||
approve [id] Approve pending bypass request (session-level)
|
||||
deny [id] Deny pending bypass request (session-level)
|
||||
read/write <path> Access agent files (chat, cfg, etc.)
|
||||
read/write <path> Access agent files (log, cfg, etc.)
|
||||
|
||||
Common:
|
||||
read [-l] <path> Read a file (-l: loop)
|
||||
|
|
@ -648,7 +648,7 @@ Examples:
|
|||
o myproj/coding new ~/src # create with specific cwd
|
||||
o myproj ls # list session contents
|
||||
o myproj/coding prompt # interactive prompt
|
||||
o myproj/coding read chat # read chat stream
|
||||
o myproj/coding read log # read text log
|
||||
o myproj bypass # show pending bypass details
|
||||
o myproj approve # approve pending bypass
|
||||
o myproj approve 42 # approve specific request by id
|
||||
|
|
|
|||
|
|
@ -87,10 +87,10 @@ echo "name=coding cwd=$PWD" | ollie-9p write session/myproj/agent/new
|
|||
| `agent/idx` | r | Agent index: `session-id\tagent-id\tagent-name\tparent-id\tdepth\tstate`. |
|
||||
| `agent/{aname}/prompt` | w | Queue a user turn. |
|
||||
| `agent/{aname}/fifo` | r/w | Prompt queue. |
|
||||
| `agent/{aname}/chat` | r | Filtered streaming chat output. |
|
||||
| `agent/{aname}/chat.raw` | r | Full streaming output with markers. |
|
||||
| `agent/{aname}/log` | r | Filtered text conversation (last 64KB). |
|
||||
| `agent/{aname}/log.raw` | r | Full JSONL streaming output. |
|
||||
| `agent/{aname}/block` | rdwr | Lookup block by ID (write ID, read JSON). |
|
||||
| `agent/{aname}/state` | r | Current agent state (idle, calling, thinking, paused). |
|
||||
| `agent/{aname}/log` | r | Conversation snapshot. |
|
||||
| `agent/{aname}/plan` | r/w | Persistent planning scratch space. |
|
||||
| `agent/{aname}/cfg` | r/w | Agent configuration. |
|
||||
| `agent/{aname}/ctl` | rdwr | Agent controls and queries. |
|
||||
|
|
@ -106,7 +106,7 @@ Submit and observe work:
|
|||
```sh
|
||||
echo 'fix the bug in main.go' \
|
||||
| ollie-9p write session/myproj/agent/coding/prompt
|
||||
ollie-9p read session/myproj/agent/coding/chat
|
||||
ollie-9p read session/myproj/agent/coding/log.raw
|
||||
# Wait for any agent state to change
|
||||
ollie-9p read event | grep "\.state"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ An integration reads editor state, writes a prompt to an agent, and reads the re
|
|||
editor or IDE
|
||||
├─ file, selection, cursor, project, or diff
|
||||
└─ 9P client → agent/.../prompt
|
||||
├─ chat / chat.raw
|
||||
├─ log / log.raw
|
||||
├─ state
|
||||
├─ status
|
||||
└─ ctl
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ For the public protocol and namespace, see [`architecture-9p.md`](architecture-9
|
|||
### URL Scheme
|
||||
|
||||
```
|
||||
ollie://session/agent#block
|
||||
ollie://session/agent#blockId
|
||||
ollie://session/agent
|
||||
ollie://session
|
||||
```
|
||||
|
|
@ -103,10 +103,12 @@ ollie://session
|
|||
|---|---|
|
||||
| `session` | Session name (required) |
|
||||
| `agent` | Agent name (optional) |
|
||||
| `#block` | Block index to scroll to (optional) |
|
||||
| `#blockId` | 8-character hex block ID to fetch (optional) |
|
||||
|
||||
Block IDs are deterministic SHA256-derived identifiers. Use `log.raw` (JSONL) to see block IDs in the `"id"` field.
|
||||
|
||||
Examples:
|
||||
- `ollie://default/main#42` — switch to session "default", agent "main", scroll to block 42
|
||||
- `ollie://default/main#a1b2c3d4` — fetch block "a1b2c3d4" from agent "main" in session "default"
|
||||
- `ollie://myproject/src:kate-custom` — switch to agent "src:kate-custom" in session "myproject"
|
||||
- `ollie://default` — switch to session "default"
|
||||
|
||||
|
|
@ -117,7 +119,7 @@ Examples:
|
|||
```
|
||||
# ollie:// URLs go to the ollie port
|
||||
type is text
|
||||
data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[0-9]+)?'
|
||||
data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[a-f0-9]+)?'
|
||||
plumb to ollie
|
||||
```
|
||||
|
||||
|
|
@ -140,7 +142,7 @@ cat $HOME/lib/plumbing | 9p write plumb/rules
|
|||
From any Plan 9 application (acme, sam, rc, etc.):
|
||||
|
||||
```sh
|
||||
plumb 'ollie://default/main#100'
|
||||
plumb 'ollie://default/main#a1b2c3d4'
|
||||
```
|
||||
|
||||
Or right-click an `ollie://` URL in acme/sam and plumb it.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
// Package format provides chat log serialization.
|
||||
//
|
||||
// The chat log uses JSON Lines format: one JSON object per line.
|
||||
// Each line is a complete, self-contained block.
|
||||
//
|
||||
// For streaming, blocks can be partial (Partial=true). The GUI shows
|
||||
// partial blocks immediately; when the final block arrives (same ID,
|
||||
// Partial=false), it replaces the partial version.
|
||||
package format
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Block represents a single chat log entry.
|
||||
type Block struct {
|
||||
Role string `json:"role"` // user, assistant, context, reasoning, call, tool, error, info, retry, stalled
|
||||
ID string `json:"id"` // 8-char hex block ID
|
||||
Content string `json:"content,omitempty"` // Block content (text, args for call, output for tool)
|
||||
Name string `json:"name,omitempty"` // Tool/function name (for call blocks)
|
||||
Format string `json:"format,omitempty"` // Output format hint (for tool blocks)
|
||||
Partial bool `json:"partial,omitempty"` // True if streaming (not final)
|
||||
}
|
||||
|
||||
// MarshalBlock returns a JSONL line for the block (with trailing newline).
|
||||
func MarshalBlock(b Block) []byte {
|
||||
data, _ := json.Marshal(b)
|
||||
return append(data, '\n')
|
||||
}
|
||||
|
||||
// UnmarshalBlock parses a JSONL line into a Block.
|
||||
func UnmarshalBlock(line []byte) (Block, error) {
|
||||
var b Block
|
||||
err := json.Unmarshal(line, &b)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// RenderBlock returns human-readable text for a block (for log file).
|
||||
// Partial blocks are skipped (only final blocks render).
|
||||
func RenderBlock(b Block) string {
|
||||
if b.Partial {
|
||||
return ""
|
||||
}
|
||||
switch b.Role {
|
||||
case "user":
|
||||
return "You: " + b.Content + "\n\n"
|
||||
case "assistant":
|
||||
return b.Content + "\n\n"
|
||||
case "context":
|
||||
return "" // Hidden from rendered log
|
||||
case "reasoning":
|
||||
return "" // Hidden from rendered log
|
||||
case "call":
|
||||
return "" // Hidden from rendered log
|
||||
case "tool":
|
||||
return "" // Hidden from rendered log
|
||||
case "error":
|
||||
return "Error: " + b.Content + "\n\n"
|
||||
case "info":
|
||||
return "Info: " + b.Content + "\n\n"
|
||||
case "retry":
|
||||
return "Retry: " + b.Content + "\n\n"
|
||||
case "stalled":
|
||||
return "[Stalled]\n\n"
|
||||
default:
|
||||
return b.Content + "\n\n"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatEvent converts event data to bytes for appending to a chat log.
|
||||
// Streaming roles (assistant, reasoning, retry) return only the content chunk;
|
||||
// the caller is responsible for writing the [[[role]]] header for those.
|
||||
// If blockID is non-empty, it is included in the block header.
|
||||
func FormatEvent(role, name, content, outputFormat, blockID string) []byte {
|
||||
switch role {
|
||||
case "user":
|
||||
if blockID != "" {
|
||||
return []byte(fmt.Sprintf("[[[user#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
|
||||
}
|
||||
return []byte(UserTag + "\n" + content + "\n" + EndTag + "\n")
|
||||
case "assistant":
|
||||
return []byte(content)
|
||||
case "reasoning":
|
||||
return []byte(content)
|
||||
case "call":
|
||||
args := strings.Join(strings.Fields(content), " ")
|
||||
if blockID != "" {
|
||||
return []byte(fmt.Sprintf("[[[call:%s#%s]]]\n", name, blockID) + "```\n" + args + "\n```\n" + EndTag + "\n")
|
||||
}
|
||||
return []byte(CallDelim(name) + "```\n" + args + "\n```\n" + EndTag + "\n")
|
||||
case "tool":
|
||||
fence := "```"
|
||||
if outputFormat != "" {
|
||||
fence += outputFormat
|
||||
}
|
||||
if blockID != "" {
|
||||
return []byte(fmt.Sprintf("[[[tool:%s#%s]]]\n", name, blockID) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n")
|
||||
}
|
||||
return []byte(ToolDelim(name) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n")
|
||||
case "error":
|
||||
r := "error"
|
||||
if name != "" {
|
||||
r = "error:" + name
|
||||
}
|
||||
if blockID != "" {
|
||||
return []byte(fmt.Sprintf("[[[%s#%s]]]\n", r, blockID) + content + "\n" + EndTag + "\n")
|
||||
}
|
||||
return []byte(RoleDelim(r) + "\n" + content + "\n" + EndTag + "\n")
|
||||
case "info":
|
||||
if blockID != "" {
|
||||
return []byte(fmt.Sprintf("[[[info#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
|
||||
}
|
||||
return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n")
|
||||
case "retry":
|
||||
return []byte(content)
|
||||
case "stalled":
|
||||
if blockID != "" {
|
||||
return []byte(fmt.Sprintf("[[[stalled#%s]]]\n", blockID) + EndTag + "\n")
|
||||
}
|
||||
return []byte(RoleDelim("stalled") + "\n" + EndTag + "\n")
|
||||
case "maxsteps":
|
||||
if blockID != "" {
|
||||
return []byte(fmt.Sprintf("[[[maxsteps#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
|
||||
}
|
||||
return []byte(RoleDelim("maxsteps") + "\n" + content + "\n" + EndTag + "\n")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
107
format/format.go
107
format/format.go
|
|
@ -1,107 +0,0 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Formatting constants for the 9P chat log protocol.
|
||||
// Used by fs.NewEventHandler and fs.replayMessagesToLog.
|
||||
//
|
||||
// Block format: [[[role:name#blockId]]] or [[[role#blockId]]]
|
||||
// The blockId is a hex string that uniquely identifies the block.
|
||||
|
||||
const (
|
||||
// EndTag marks the end of a message block.
|
||||
EndTag = "[[[end]]]"
|
||||
|
||||
// RoleTag formats a role delimiter, e.g. "[[[user]]]".
|
||||
RoleTag = "[[[%s]]]"
|
||||
|
||||
// ToolFence is the opening fence for tool output blocks.
|
||||
ToolFence = "```\n"
|
||||
|
||||
// AssistantHeaderPrefix is prepended to assistant headers when a response ID is present.
|
||||
AssistantHeaderPrefix = "[[[assistant:"
|
||||
|
||||
// AssistantTag is the bare assistant header (no response ID).
|
||||
AssistantTag = "[[[assistant]]]"
|
||||
|
||||
// UserTag is the user role delimiter.
|
||||
UserTag = "[[[user]]]"
|
||||
|
||||
// RetryTag is the retry role delimiter.
|
||||
RetryTag = "[[[retry]]]"
|
||||
|
||||
// ToolDelimPrefix is the prefix for tool delimiters, e.g. "[[[tool:shell]]]".
|
||||
ToolDelimPrefix = "[[[tool:"
|
||||
|
||||
// ToolTag is the bare tool role delimiter (no name).
|
||||
ToolTag = "[[[tool]]]"
|
||||
|
||||
// CallDelimPrefix is the prefix for call delimiters, e.g. "[[[call:file_read]]]".
|
||||
CallDelimPrefix = "[[[call:"
|
||||
)
|
||||
|
||||
// RoleDelim returns a formatted role delimiter.
|
||||
func RoleDelim(role string) string {
|
||||
return fmt.Sprintf(RoleTag, role)
|
||||
}
|
||||
|
||||
// ToolDelim returns a formatted tool delimiter.
|
||||
func ToolDelim(name string) string {
|
||||
return fmt.Sprintf(ToolDelimPrefix+"%s]]]\n", name)
|
||||
}
|
||||
|
||||
// CallDelim returns a formatted call delimiter.
|
||||
func CallDelim(name string) string {
|
||||
return fmt.Sprintf(CallDelimPrefix+"%s]]]\n", name)
|
||||
}
|
||||
|
||||
// BlockDelim returns a formatted block delimiter with a block ID.
|
||||
// Format: [[[role#blockId]]] or [[[role:name#blockId]]]
|
||||
func BlockDelim(role, name, blockID string) string {
|
||||
if name != "" {
|
||||
return fmt.Sprintf("[[[%s:%s#%s]]]\n", role, name, blockID)
|
||||
}
|
||||
return fmt.Sprintf("[[[%s#%s]]]\n", role, blockID)
|
||||
}
|
||||
|
||||
// BlockHeader is a single-line [[[...]]] marker that opens a log block.
|
||||
type BlockHeader struct {
|
||||
Role string // e.g. "user", "assistant", "tool", "retry", or "tool:shell"
|
||||
Name string // for tool/call: the sub-name (e.g. "shell"), empty for bare roles
|
||||
BlockID string // unique block identifier (hex), empty if not present
|
||||
}
|
||||
|
||||
// ParseBlockHeader extracts a BlockHeader from a line like "[[[tool:shell#a1b2]]]"
|
||||
// or "[[[assistant]]]". Returns nil if the line is not a valid block header.
|
||||
func ParseBlockHeader(line string) *BlockHeader {
|
||||
const prefix, suffix = "[[[", "]]]"
|
||||
if !strings.HasPrefix(line, prefix) || !strings.HasSuffix(line, suffix) {
|
||||
return nil
|
||||
}
|
||||
inner := strings.TrimPrefix(line, prefix)
|
||||
inner = strings.TrimSuffix(inner, suffix)
|
||||
if inner == "" {
|
||||
return nil
|
||||
}
|
||||
bh := &BlockHeader{Role: inner}
|
||||
// Extract blockId if present (after #)
|
||||
if idx := strings.LastIndex(inner, "#"); idx >= 0 {
|
||||
bh.BlockID = inner[idx+1:]
|
||||
inner = inner[:idx]
|
||||
bh.Role = inner
|
||||
}
|
||||
// Extract name if present (after :)
|
||||
if idx := strings.Index(inner, ":"); idx >= 0 {
|
||||
bh.Role = inner[:idx]
|
||||
bh.Name = inner[idx+1:]
|
||||
}
|
||||
return bh
|
||||
}
|
||||
|
||||
// IsEndMarker reports whether the line is exactly the [[[end]]] closing marker.
|
||||
func IsEndMarker(line string) bool {
|
||||
return line == EndTag
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBlockHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
wantNil bool
|
||||
wantRole string
|
||||
wantName string
|
||||
wantID string
|
||||
}{
|
||||
// Valid headers
|
||||
{"[[[user]]]", false, "user", "", ""},
|
||||
{"[[[user#abc12345]]]", false, "user", "", "abc12345"},
|
||||
{"[[[assistant]]]", false, "assistant", "", ""},
|
||||
{"[[[assistant#def67890]]]", false, "assistant", "", "def67890"},
|
||||
{"[[[assistant:resp_123#aabbccdd]]]", false, "assistant", "resp_123", "aabbccdd"},
|
||||
{"[[[tool:shell]]]", false, "tool", "shell", ""},
|
||||
{"[[[tool:shell#11223344]]]", false, "tool", "shell", "11223344"},
|
||||
{"[[[call:file_read]]]", false, "call", "file_read", ""},
|
||||
{"[[[call:file_read#99887766]]]", false, "call", "file_read", "99887766"},
|
||||
{"[[[reasoning]]]", false, "reasoning", "", ""},
|
||||
{"[[[reasoning#aabbcc00]]]", false, "reasoning", "", "aabbcc00"},
|
||||
{"[[[retry]]]", false, "retry", "", ""},
|
||||
{"[[[end]]]", false, "end", "", ""},
|
||||
{"[[[error]]]", false, "error", "", ""},
|
||||
{"[[[error:file_read#12345678]]]", false, "error", "file_read", "12345678"},
|
||||
{"[[[info]]]", false, "info", "", ""},
|
||||
{"[[[stalled]]]", false, "stalled", "", ""},
|
||||
|
||||
// Invalid headers
|
||||
{"", true, "", "", ""},
|
||||
{"[[[]]]", true, "", "", ""},
|
||||
{"not a header", true, "", "", ""},
|
||||
{"[[user]]", true, "", "", ""},
|
||||
{"[[[user", true, "", "", ""},
|
||||
{"user]]]", true, "", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
bh := ParseBlockHeader(tt.line)
|
||||
if tt.wantNil {
|
||||
if bh != nil {
|
||||
t.Errorf("ParseBlockHeader(%q): expected nil, got %+v", tt.line, bh)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if bh == nil {
|
||||
t.Errorf("ParseBlockHeader(%q): expected non-nil", tt.line)
|
||||
continue
|
||||
}
|
||||
if bh.Role != tt.wantRole {
|
||||
t.Errorf("ParseBlockHeader(%q): role = %q, want %q", tt.line, bh.Role, tt.wantRole)
|
||||
}
|
||||
if bh.Name != tt.wantName {
|
||||
t.Errorf("ParseBlockHeader(%q): name = %q, want %q", tt.line, bh.Name, tt.wantName)
|
||||
}
|
||||
if bh.BlockID != tt.wantID {
|
||||
t.Errorf("ParseBlockHeader(%q): blockID = %q, want %q", tt.line, bh.BlockID, tt.wantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockDelim(t *testing.T) {
|
||||
tests := []struct {
|
||||
role, name, blockID string
|
||||
want string
|
||||
}{
|
||||
{"user", "", "abc12345", "[[[user#abc12345]]]\n"},
|
||||
{"assistant", "", "def67890", "[[[assistant#def67890]]]\n"},
|
||||
{"tool", "shell", "11223344", "[[[tool:shell#11223344]]]\n"},
|
||||
{"call", "file_read", "99887766", "[[[call:file_read#99887766]]]\n"},
|
||||
{"error", "test", "aabbccdd", "[[[error:test#aabbccdd]]]\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := BlockDelim(tt.role, tt.name, tt.blockID)
|
||||
if got != tt.want {
|
||||
t.Errorf("BlockDelim(%q, %q, %q) = %q, want %q", tt.role, tt.name, tt.blockID, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEvent_WithBlockID(t *testing.T) {
|
||||
tests := []struct {
|
||||
role, name, content, outputFormat, blockID string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
"user", "", "hello", "", "abc12345",
|
||||
[]string{"[[[user#abc12345]]]", "hello", "[[[end]]]"},
|
||||
},
|
||||
{
|
||||
"call", "shell", `{"cmd": "ls"}`, "", "def67890",
|
||||
[]string{"[[[call:shell#def67890]]]", `{"cmd": "ls"}`, "[[[end]]]"},
|
||||
},
|
||||
{
|
||||
"tool", "shell", "output", "text", "11223344",
|
||||
[]string{"[[[tool:shell#11223344]]]", "output", "[[[end]]]"},
|
||||
},
|
||||
{
|
||||
"error", "", "something failed", "", "aabbccdd",
|
||||
[]string{"[[[error#aabbccdd]]]", "something failed", "[[[end]]]"},
|
||||
},
|
||||
{
|
||||
"error", "test", "something failed", "", "eeff0011",
|
||||
[]string{"[[[error:test#eeff0011]]]", "something failed", "[[[end]]]"},
|
||||
},
|
||||
{
|
||||
"info", "", "status update", "", "22334455",
|
||||
[]string{"[[[info#22334455]]]", "status update", "[[[end]]]"},
|
||||
},
|
||||
{
|
||||
"stalled", "", "", "", "66778899",
|
||||
[]string{"[[[stalled#66778899]]]", "[[[end]]]"},
|
||||
},
|
||||
{
|
||||
"maxsteps", "", "limit reached", "", "aabbcc00",
|
||||
[]string{"[[[maxsteps#aabbcc00]]]", "limit reached", "[[[end]]]"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := string(FormatEvent(tt.role, tt.name, tt.content, tt.outputFormat, tt.blockID))
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("FormatEvent(%q, %q, ...): missing %q in:\n%s", tt.role, tt.name, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEndMarker(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want bool
|
||||
}{
|
||||
{"[[[end]]]", true},
|
||||
{"[[[end]]", false},
|
||||
{"[[end]]]", false},
|
||||
{"[[[END]]]", false},
|
||||
{"[[[end]]] ", false},
|
||||
{" [[[end]]]", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := IsEndMarker(tt.line)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsEndMarker(%q) = %v, want %v", tt.line, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundtrip_ParseBlockHeader(t *testing.T) {
|
||||
// Test that BlockDelim output can be parsed by ParseBlockHeader
|
||||
tests := []struct {
|
||||
role, name, blockID string
|
||||
}{
|
||||
{"user", "", "abc12345"},
|
||||
{"tool", "shell", "def67890"},
|
||||
{"call", "file_read", "11223344"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
delim := BlockDelim(tt.role, tt.name, tt.blockID)
|
||||
// Trim the trailing newline for parsing
|
||||
delim = strings.TrimSuffix(delim, "\n")
|
||||
bh := ParseBlockHeader(delim)
|
||||
if bh == nil {
|
||||
t.Errorf("ParseBlockHeader(BlockDelim(%q, %q, %q)) returned nil", tt.role, tt.name, tt.blockID)
|
||||
continue
|
||||
}
|
||||
if bh.Role != tt.role {
|
||||
t.Errorf("roundtrip role: got %q, want %q", bh.Role, tt.role)
|
||||
}
|
||||
if bh.Name != tt.name {
|
||||
t.Errorf("roundtrip name: got %q, want %q", bh.Name, tt.name)
|
||||
}
|
||||
if bh.BlockID != tt.blockID {
|
||||
t.Errorf("roundtrip blockID: got %q, want %q", bh.BlockID, tt.blockID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,14 +14,13 @@ Column {
|
|||
|
||||
// Required properties from model
|
||||
required property string blockType
|
||||
required property string blockName
|
||||
required property var blockSegments
|
||||
required property string blockRenderedHtml
|
||||
required property int index
|
||||
required property string blockId
|
||||
|
||||
// Bookmark functions (connected from parent)
|
||||
property var addBookmark: function(sid, sn, aid, an, bid, bt, bn, p) { return false }
|
||||
property var addBookmark: function(sid, sn, aid, an, bid, bt, p) { return false }
|
||||
property var removeBookmark: function(sid, aid, bid) { return false }
|
||||
property var checkBookmarked: function(sid, aid, bid) { return false }
|
||||
property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockId)
|
||||
|
|
@ -82,8 +81,8 @@ Column {
|
|||
case "user": return "\uD83E\uDDD1 You"
|
||||
case "assistant": return "\uD83E\uDD16 Ollie"
|
||||
case "reasoning": return "Reasoning"
|
||||
case "call": return "\u25B6\uFE0F " + blockName
|
||||
case "tool": return "\uD83D\uDDA5\uFE0F " + blockName
|
||||
case "call": return "\u25B6\uFE0F Call"
|
||||
case "tool": return "\uD83D\uDDA5\uFE0F Tool"
|
||||
case "error": return "\u274C Error"
|
||||
case "info": return "\u2139\uFE0F Info"
|
||||
case "retry": return "Retry"
|
||||
|
|
@ -200,7 +199,7 @@ Column {
|
|||
} else {
|
||||
var sessionName = ollie.activeSessionName || ollie.activeSessionId
|
||||
var agentName = ollie.activeAgentName || ollie.activeAgentId
|
||||
var preview = getBlockPlainText() || blockName
|
||||
var preview = getBlockPlainText() || blockType
|
||||
root.addBookmark(
|
||||
ollie.activeSessionId,
|
||||
sessionName,
|
||||
|
|
@ -208,7 +207,6 @@ Column {
|
|||
agentName,
|
||||
root.blockId,
|
||||
blockType,
|
||||
blockName,
|
||||
preview
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
#include "chatblockmodel.h"
|
||||
|
||||
#include <QTextDocument>
|
||||
#include <QRegularExpressionMatch>
|
||||
#include <QApplication>
|
||||
#include <QPalette>
|
||||
#include <QNetworkAccessManager>
|
||||
|
|
@ -10,6 +9,9 @@
|
|||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QCryptographicHash>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <KSyntaxHighlighting/Repository>
|
||||
#include <KSyntaxHighlighting/Definition>
|
||||
|
|
@ -81,34 +83,24 @@ private:
|
|||
|
||||
// --- Static helpers ---
|
||||
|
||||
static ChatBlock::Type blockTypeFromString(const QString &typeStr)
|
||||
ChatBlock::Type ChatBlockModel::blockTypeFromString(const QString &role)
|
||||
{
|
||||
if (typeStr == "user") return ChatBlock::User;
|
||||
if (typeStr == "assistant") return ChatBlock::Assistant;
|
||||
if (typeStr == "reasoning") return ChatBlock::Reasoning;
|
||||
if (typeStr == "call") return ChatBlock::Call;
|
||||
if (typeStr == "tool") return ChatBlock::Tool;
|
||||
if (typeStr == "error") return ChatBlock::Error;
|
||||
if (typeStr == "info") return ChatBlock::Info;
|
||||
if (typeStr == "retry") return ChatBlock::Retry;
|
||||
if (role == "user") return ChatBlock::User;
|
||||
if (role == "assistant") return ChatBlock::Assistant;
|
||||
if (role == "context") return ChatBlock::Context;
|
||||
if (role == "reasoning") return ChatBlock::Reasoning;
|
||||
if (role == "call") return ChatBlock::Call;
|
||||
if (role == "tool") return ChatBlock::Tool;
|
||||
if (role == "error") return ChatBlock::Error;
|
||||
if (role == "info") return ChatBlock::Info;
|
||||
if (role == "retry") return ChatBlock::Retry;
|
||||
if (role == "interrupt") return ChatBlock::Interrupt;
|
||||
return ChatBlock::Default;
|
||||
}
|
||||
|
||||
// --- Block Parsing ---
|
||||
|
||||
const QRegularExpression ChatBlockModel::s_headerRe(
|
||||
R"(^\[\[\[([^\]]+)\]\]\]$)");
|
||||
|
||||
const QRegularExpression ChatBlockModel::s_endRe(
|
||||
R"(^\[\[\[end\]\]\]$)");
|
||||
|
||||
// Matches opening fence: ```lang or just ```
|
||||
const QRegularExpression ChatBlockModel::s_fenceOpenRe(
|
||||
R"(^```([A-Za-z0-9_+.-]*).*$)");
|
||||
|
||||
// Matches closing fence: ``` on its own line
|
||||
const QRegularExpression ChatBlockModel::s_fenceCloseRe(
|
||||
R"(^```$)");
|
||||
// Code fence regex (used for content rendering, not block parsing)
|
||||
static const QRegularExpression s_fenceOpenRe(R"(^```([A-Za-z0-9_+.-]*).*$)");
|
||||
static const QRegularExpression s_fenceCloseRe(R"(^```$)");
|
||||
|
||||
ChatBlockModel::ChatBlockModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
|
|
@ -141,6 +133,7 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
|
|||
switch (b.type) {
|
||||
case ChatBlock::User: return QStringLiteral("user");
|
||||
case ChatBlock::Assistant: return QStringLiteral("assistant");
|
||||
case ChatBlock::Context: return QStringLiteral("context");
|
||||
case ChatBlock::Reasoning: return QStringLiteral("reasoning");
|
||||
case ChatBlock::Call: return QStringLiteral("call");
|
||||
case ChatBlock::Tool: return QStringLiteral("tool");
|
||||
|
|
@ -151,8 +144,6 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
|
|||
case ChatBlock::Default: return QStringLiteral("default");
|
||||
}
|
||||
return {};
|
||||
case BlockNameRole:
|
||||
return b.name;
|
||||
case ContentRole:
|
||||
return b.content;
|
||||
case RenderedHtmlRole:
|
||||
|
|
@ -161,6 +152,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
|
|||
return segmentsToVariant(b.segments);
|
||||
case BlockIdRole:
|
||||
return b.blockId;
|
||||
case PartialRole:
|
||||
return b.partial;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
@ -189,44 +182,88 @@ QHash<int, QByteArray> ChatBlockModel::roleNames() const
|
|||
{
|
||||
return {
|
||||
{BlockTypeRole, "blockType"},
|
||||
{BlockNameRole, "blockName"},
|
||||
{ContentRole, "blockContent"},
|
||||
{RenderedHtmlRole, "blockRenderedHtml"},
|
||||
{SegmentsRole, "blockSegments"},
|
||||
{BlockIdRole, "blockId"},
|
||||
{PartialRole, "blockPartial"},
|
||||
};
|
||||
}
|
||||
|
||||
void ChatBlockModel::appendData(const QString &text)
|
||||
{
|
||||
m_lineBuffer += text;
|
||||
qDebug() << "ChatBlockModel::appendData len=" << text.length() << "text=" << text.left(200);
|
||||
m_lineBuffer += text.toUtf8();
|
||||
|
||||
// Process complete JSONL lines
|
||||
int idx;
|
||||
while ((idx = m_lineBuffer.indexOf('\n')) >= 0) {
|
||||
QString line = m_lineBuffer.left(idx);
|
||||
QByteArray line = m_lineBuffer.left(idx);
|
||||
m_lineBuffer = m_lineBuffer.mid(idx + 1);
|
||||
processLine(line);
|
||||
if (!line.isEmpty()) {
|
||||
qDebug() << "ChatBlockModel: processing line:" << line.left(100);
|
||||
processJsonLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChatBlockModel::processJsonLine(const QByteArray &line)
|
||||
{
|
||||
QJsonParseError error;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(line, &error);
|
||||
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
qWarning() << "ChatBlockModel: invalid JSON line:" << error.errorString();
|
||||
return;
|
||||
}
|
||||
|
||||
// For streaming: update current block with partial line buffer content
|
||||
// so the UI shows text as it arrives, not just after newlines.
|
||||
if (!m_lineBuffer.isEmpty() && !m_blocks.isEmpty() && m_state == State::BlockEnd) {
|
||||
int blockIndex = m_blocks.size() - 1;
|
||||
ChatBlock &last = m_blocks[blockIndex];
|
||||
QJsonObject obj = doc.object();
|
||||
QString role = obj["role"].toString();
|
||||
QString id = obj["id"].toString();
|
||||
QString content = obj["content"].toString();
|
||||
QString name = obj["name"].toString();
|
||||
QString format = obj["format"].toString();
|
||||
bool partial = obj["partial"].toBool(false);
|
||||
|
||||
// Save current segment state, render with partial line, then restore
|
||||
QList<BlockSegment> savedSegments = last.segments;
|
||||
QString savedHtml = last.renderedHtml;
|
||||
qDebug() << "ChatBlockModel::processJsonLine role=" << role << "id=" << id << "content.len=" << content.length();
|
||||
|
||||
renderLastSegmentPartial(blockIndex, m_lineBuffer);
|
||||
// Skip context blocks from rendering (context is for LLM, not display)
|
||||
if (role == "context") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit the partial-updated view
|
||||
QModelIndex idx = index(blockIndex);
|
||||
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
|
||||
// Find existing block with same ID (for streaming updates)
|
||||
int existingIdx = indexForBlockId(id);
|
||||
|
||||
// Restore — partial line not committed yet
|
||||
last.segments = savedSegments;
|
||||
last.renderedHtml = savedHtml;
|
||||
if (existingIdx >= 0) {
|
||||
// Update existing block
|
||||
ChatBlock &block = m_blocks[existingIdx];
|
||||
block.content = content;
|
||||
block.partial = partial;
|
||||
rebuildBlockHtml(existingIdx);
|
||||
|
||||
QModelIndex idx = index(existingIdx);
|
||||
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole, PartialRole});
|
||||
} else {
|
||||
// New block
|
||||
int newIdx = m_blocks.size();
|
||||
beginInsertRows(QModelIndex(), newIdx, newIdx);
|
||||
|
||||
ChatBlock block;
|
||||
block.type = blockTypeFromString(role);
|
||||
block.blockId = id;
|
||||
block.content = content;
|
||||
block.name = name;
|
||||
block.format = format;
|
||||
block.partial = partial;
|
||||
|
||||
m_blocks.append(block);
|
||||
endInsertRows();
|
||||
emit countChanged();
|
||||
|
||||
// Render after insertion so rebuildBlockHtml can find it
|
||||
rebuildBlockHtml(newIdx);
|
||||
QModelIndex idx = index(newIdx);
|
||||
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,14 +272,8 @@ void ChatBlockModel::reset()
|
|||
beginResetModel();
|
||||
m_blocks.clear();
|
||||
m_lineBuffer.clear();
|
||||
m_inContext = false;
|
||||
m_state = State::BlockStart;
|
||||
m_inCodeFence = false;
|
||||
m_fenceLanguage.clear();
|
||||
m_fenceContent.clear();
|
||||
m_pendingDiagrams.clear();
|
||||
m_inFlightRequests.clear();
|
||||
m_blockIdCounter = 0; // Reset so same chat produces same IDs
|
||||
endResetModel();
|
||||
emit countChanged();
|
||||
}
|
||||
|
|
@ -418,6 +449,37 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
return;
|
||||
|
||||
ChatBlock &block = m_blocks[blockIndex];
|
||||
|
||||
// For call blocks, show tool name and JSON formatted args
|
||||
if (block.type == ChatBlock::Call) {
|
||||
QString html = QStringLiteral("<div style=\"color:") + getDefaultTextColor() + QStringLiteral(";\">");
|
||||
if (!block.name.isEmpty()) {
|
||||
html += QStringLiteral("<b>") + block.name.toHtmlEscaped() + QStringLiteral("</b><br/>");
|
||||
}
|
||||
html += highlightCode(block.content, QStringLiteral("json"));
|
||||
html += QStringLiteral("</div>");
|
||||
block.renderedHtml = html;
|
||||
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = html;
|
||||
block.segments = {seg};
|
||||
return;
|
||||
}
|
||||
|
||||
// For tool blocks, highlight with format hint
|
||||
if (block.type == ChatBlock::Tool) {
|
||||
QString lang = block.format.isEmpty() ? QStringLiteral("text") : block.format;
|
||||
block.renderedHtml = highlightCode(block.content, lang);
|
||||
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = block.renderedHtml;
|
||||
block.segments = {seg};
|
||||
return;
|
||||
}
|
||||
|
||||
// For other blocks, render content with code fence detection
|
||||
QString html;
|
||||
QList<BlockSegment> segments;
|
||||
|
||||
|
|
@ -504,9 +566,9 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
}
|
||||
}
|
||||
|
||||
// Handle unclosed fence (streaming) or remaining prose
|
||||
// Handle any remaining content
|
||||
if (inFence) {
|
||||
// Still inside a fence — render what we have (incomplete)
|
||||
// Unclosed fence (streaming)
|
||||
flushFence(false);
|
||||
} else {
|
||||
flushProse();
|
||||
|
|
@ -514,209 +576,6 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
|
||||
block.renderedHtml = html;
|
||||
block.segments = segments;
|
||||
// Reset incremental state to match the full rebuild
|
||||
block.renderState = BlockRenderState();
|
||||
}
|
||||
|
||||
void ChatBlockModel::appendContent(const QString &line)
|
||||
{
|
||||
if (m_blocks.isEmpty())
|
||||
return;
|
||||
|
||||
int blockIndex = m_blocks.size() - 1;
|
||||
ChatBlock &last = m_blocks[blockIndex];
|
||||
if (!last.content.isEmpty())
|
||||
last.content += '\n';
|
||||
last.content += line;
|
||||
|
||||
incrementalAppend(blockIndex, line);
|
||||
|
||||
QModelIndex idx = index(blockIndex);
|
||||
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
|
||||
}
|
||||
|
||||
void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block)
|
||||
{
|
||||
QString html;
|
||||
for (const BlockSegment &seg : block.segments)
|
||||
html += seg.html;
|
||||
block.renderedHtml = html;
|
||||
}
|
||||
|
||||
void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
|
||||
{
|
||||
ChatBlock &block = m_blocks[blockIndex];
|
||||
BlockRenderState &rs = block.renderState;
|
||||
rs.committedLineCount++;
|
||||
|
||||
if (!rs.inFence) {
|
||||
// Check for fence open
|
||||
auto match = s_fenceOpenRe.match(line);
|
||||
if (match.hasMatch()) {
|
||||
// Flush any accumulated table into a finalized segment
|
||||
if (rs.inTable && !rs.tableLines.isEmpty()) {
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = renderTable(rs.tableLines);
|
||||
rs.tableLines.clear();
|
||||
rs.inTable = false;
|
||||
}
|
||||
// Flush any accumulated prose into a finalized text segment
|
||||
if (!rs.proseLines.isEmpty()) {
|
||||
// The last segment should already be Text with these prose lines;
|
||||
// it was being built up incrementally. Mark it done.
|
||||
rs.proseLines.clear();
|
||||
}
|
||||
rs.inFence = true;
|
||||
rs.fenceLang = match.captured(1);
|
||||
rs.fenceLines.clear();
|
||||
|
||||
// Add a new Text segment for the code fence (will be rebuilt each line)
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = highlightCode(QString(), rs.fenceLang);
|
||||
block.segments.append(seg);
|
||||
block.renderedHtml += seg.html;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTableLine(line)) {
|
||||
// Transitioning from prose to table — flush prose, start table segment
|
||||
if (!rs.inTable) {
|
||||
if (!rs.proseLines.isEmpty())
|
||||
rs.proseLines.clear();
|
||||
rs.inTable = true;
|
||||
rs.tableLines.clear();
|
||||
rs.tableLines.append(line);
|
||||
|
||||
// Start a new segment for the table
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = renderTable(rs.tableLines);
|
||||
block.segments.append(seg);
|
||||
} else {
|
||||
// Continue accumulating table lines
|
||||
rs.tableLines.append(line);
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = renderTable(rs.tableLines);
|
||||
}
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-table prose line
|
||||
if (rs.inTable) {
|
||||
// Table just ended — finalize the table segment, start new prose
|
||||
rs.tableLines.clear();
|
||||
rs.inTable = false;
|
||||
}
|
||||
|
||||
// Normal prose line — extend or create last text segment
|
||||
rs.proseLines.append(line);
|
||||
|
||||
// Incremental: escape only the new line and append to existing segment
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
const QString escapedLine = line.toHtmlEscaped();
|
||||
|
||||
// Only extend the last segment if it is an in-progress prose segment
|
||||
// (identified by its trailing </div>). A finalized code fence segment
|
||||
// ends in </pre> and must not be overwritten.
|
||||
bool canExtend = !block.segments.isEmpty()
|
||||
&& block.segments.last().type == BlockSegment::Text
|
||||
&& block.segments.last().html.endsWith(QStringLiteral("</div>"));
|
||||
|
||||
if (canExtend) {
|
||||
QString &segHtml = block.segments.last().html;
|
||||
segHtml.chop(6); // remove trailing </div>
|
||||
segHtml += '\n' + escapedLine + QStringLiteral("</div>");
|
||||
} else {
|
||||
// Start a new prose segment
|
||||
rs.proseLines.clear();
|
||||
rs.proseLines.append(line);
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ escapedLine + QStringLiteral("</div>");
|
||||
block.segments.append(seg);
|
||||
}
|
||||
// Rebuild renderedHtml from segments (concatenation is cheap)
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
} else {
|
||||
// Inside a fence — check for close
|
||||
if (s_fenceCloseRe.match(line).hasMatch()) {
|
||||
// Close the fence — finalize the segment
|
||||
rs.inFence = false;
|
||||
const QString code = rs.fenceLines.join('\n');
|
||||
|
||||
if (isDiagramLanguage(rs.fenceLang)) {
|
||||
// Replace last segment with a diagram segment
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Diagram;
|
||||
seg.diagramLang = rs.fenceLang;
|
||||
seg.diagramSource = code;
|
||||
seg.html = highlightCode(code, rs.fenceLang);
|
||||
seg.diagramLoading = true;
|
||||
requestDiagram(rs.fenceLang, code, blockIndex);
|
||||
|
||||
// Replace the in-progress fence segment
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last() = seg;
|
||||
else
|
||||
block.segments.append(seg);
|
||||
} else {
|
||||
// Normal code: finalize highlighted segment
|
||||
QString codeHtml = highlightCode(code, rs.fenceLang);
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = codeHtml;
|
||||
}
|
||||
|
||||
rs.fenceLang.clear();
|
||||
rs.fenceLines.clear();
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
} else {
|
||||
// Append line to fence — incrementally highlight only the new line
|
||||
rs.fenceLines.append(line);
|
||||
|
||||
// Get highlighter definition + theme
|
||||
auto definition = m_repo->definitionForName(rs.fenceLang);
|
||||
if (!definition.isValid())
|
||||
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
|
||||
|
||||
if (definition.isValid()) {
|
||||
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
|
||||
const auto hlTheme = dark
|
||||
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
|
||||
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
|
||||
|
||||
HtmlHighlighter highlighter;
|
||||
highlighter.setDefinition(definition);
|
||||
highlighter.setTheme(hlTheme);
|
||||
highlighter.defaultColor = getDefaultTextColor();
|
||||
|
||||
// Add line break before new line (if not the first)
|
||||
if (!rs.hlHtml.isEmpty())
|
||||
rs.hlHtml += QStringLiteral("<br/>");
|
||||
|
||||
// Process only the new line, continuing from saved state
|
||||
rs.hlState = highlighter.processLine(line, rs.hlState);
|
||||
highlighter.endLine();
|
||||
rs.hlHtml += highlighter.html;
|
||||
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = highlightCodeWrapped(rs.hlHtml);
|
||||
} else {
|
||||
// No syntax def — just escape
|
||||
if (!rs.hlHtml.isEmpty())
|
||||
rs.hlHtml += '\n';
|
||||
rs.hlHtml += line.toHtmlEscaped();
|
||||
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ rs.hlHtml + QStringLiteral("</pre>");
|
||||
}
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &partialLine)
|
||||
|
|
@ -725,345 +584,156 @@ void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &par
|
|||
return;
|
||||
|
||||
ChatBlock &block = m_blocks[blockIndex];
|
||||
BlockRenderState &rs = block.renderState;
|
||||
|
||||
if (rs.inFence) {
|
||||
// Show partial line appended to current fence — use cached state
|
||||
auto definition = m_repo->definitionForName(rs.fenceLang);
|
||||
if (!definition.isValid())
|
||||
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
|
||||
|
||||
if (definition.isValid()) {
|
||||
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
|
||||
const auto hlTheme = dark
|
||||
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
|
||||
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
|
||||
|
||||
HtmlHighlighter highlighter;
|
||||
highlighter.setDefinition(definition);
|
||||
highlighter.setTheme(hlTheme);
|
||||
highlighter.defaultColor = getDefaultTextColor();
|
||||
|
||||
// Process partial line from saved state (don't save back)
|
||||
QString partialHtml = rs.hlHtml;
|
||||
if (!partialHtml.isEmpty())
|
||||
partialHtml += QStringLiteral("<br/>");
|
||||
highlighter.processLine(partialLine, rs.hlState);
|
||||
highlighter.endLine();
|
||||
partialHtml += highlighter.html;
|
||||
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = highlightCodeWrapped(partialHtml);
|
||||
} else {
|
||||
// No definition — simple escape
|
||||
QString inner = rs.hlHtml;
|
||||
if (!inner.isEmpty())
|
||||
inner += '\n';
|
||||
inner += partialLine.toHtmlEscaped();
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ inner + QStringLiteral("</pre>");
|
||||
}
|
||||
// Append partial line to content and re-render
|
||||
QString tempContent = block.content;
|
||||
if (!tempContent.isEmpty() && !tempContent.endsWith('\n')) {
|
||||
tempContent += partialLine;
|
||||
} else {
|
||||
// Show partial line appended to current prose/table
|
||||
if (rs.inTable) {
|
||||
// Currently building a table — show partial line as part of the table
|
||||
if (isTableLine(partialLine)) {
|
||||
QStringList preview = rs.tableLines;
|
||||
preview.append(partialLine);
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = renderTable(preview);
|
||||
tempContent += partialLine;
|
||||
}
|
||||
|
||||
block.content = tempContent;
|
||||
rebuildBlockHtml(blockIndex);
|
||||
}
|
||||
|
||||
void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block)
|
||||
{
|
||||
QString html;
|
||||
for (const BlockSegment &seg : block.segments) {
|
||||
if (seg.type == BlockSegment::Diagram) {
|
||||
if (!seg.diagramSvg.isEmpty()) {
|
||||
html += seg.diagramSvg;
|
||||
} else if (!seg.diagramError.isEmpty()) {
|
||||
html += QStringLiteral("<div style=\"color:red;\">") + seg.diagramError.toHtmlEscaped() + QStringLiteral("</div>");
|
||||
} else {
|
||||
// Partial line breaks the table — just show it as escaped text after
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
const QString escapedPartial = partialLine.toHtmlEscaped();
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ escapedPartial + QStringLiteral("</div>");
|
||||
block.segments.append(seg);
|
||||
html += QStringLiteral("<div>[Loading diagram...]</div>");
|
||||
}
|
||||
} else {
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
const QString escapedPartial = partialLine.toHtmlEscaped();
|
||||
|
||||
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text) {
|
||||
// Temporarily append to the segment (caller will restore)
|
||||
QString &segHtml = block.segments.last().html;
|
||||
if (segHtml.endsWith(QStringLiteral("</div>"))) {
|
||||
segHtml.chop(6);
|
||||
segHtml += '\n' + escapedPartial + QStringLiteral("</div>");
|
||||
} else {
|
||||
segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n'));
|
||||
}
|
||||
} else {
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ escapedPartial + QStringLiteral("</div>");
|
||||
block.segments.append(seg);
|
||||
}
|
||||
html += seg.html;
|
||||
}
|
||||
}
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
block.renderedHtml = html;
|
||||
}
|
||||
|
||||
void ChatBlockModel::processLine(const QString &line)
|
||||
{
|
||||
switch (m_state) {
|
||||
case State::BlockStart: {
|
||||
// [[[blockname]]] or [[[blockname#blockId]]] starts a new block
|
||||
QRegularExpressionMatch match = s_headerRe.match(line);
|
||||
if (match.hasMatch()) {
|
||||
QString blockName = match.captured(1);
|
||||
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
|
||||
ChatBlock newBlock;
|
||||
|
||||
// Extract blockId if present (after #)
|
||||
int hashIdx = blockName.lastIndexOf('#');
|
||||
if (hashIdx >= 0) {
|
||||
newBlock.blockId = blockName.mid(hashIdx + 1);
|
||||
blockName = blockName.left(hashIdx);
|
||||
} else {
|
||||
// Fallback: generate client-side ID for legacy blocks without server IDs
|
||||
QString idSource = QString::number(m_blockIdCounter++) + blockName;
|
||||
newBlock.blockId = QString::fromLatin1(
|
||||
QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12));
|
||||
}
|
||||
|
||||
newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
|
||||
newBlock.name = blockName.section(':', 1);
|
||||
m_blocks.append(newBlock);
|
||||
endInsertRows();
|
||||
emit countChanged();
|
||||
m_state = State::BlockEnd;
|
||||
}
|
||||
// Lines outside blocks are ignored
|
||||
return;
|
||||
}
|
||||
|
||||
case State::BlockEnd:
|
||||
// Hide prompt context blocks from the rendered chat, matching olliesrv.
|
||||
if (line.trimmed() == QStringLiteral("<context>")) {
|
||||
m_inContext = true;
|
||||
return;
|
||||
}
|
||||
if (line.trimmed() == QStringLiteral("</context>")) {
|
||||
m_inContext = false;
|
||||
return;
|
||||
}
|
||||
if (m_inContext)
|
||||
return;
|
||||
|
||||
// [[[end]]] closes the current block
|
||||
if (s_endRe.match(line).hasMatch()) {
|
||||
// Emit final update so QML picks up the completed segment state.
|
||||
if (!m_blocks.isEmpty()) {
|
||||
int blockIndex = m_blocks.size() - 1;
|
||||
QModelIndex idx = index(blockIndex);
|
||||
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
|
||||
}
|
||||
m_state = State::BlockStart;
|
||||
return;
|
||||
}
|
||||
appendContent(line);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Diagram rendering ---
|
||||
// --- Diagram support ---
|
||||
|
||||
bool ChatBlockModel::isDiagramLanguage(const QString &lang) const
|
||||
{
|
||||
const QString l = lang.toLower();
|
||||
return l == QStringLiteral("plantuml") ||
|
||||
l == QStringLiteral("mermaid") ||
|
||||
l == QStringLiteral("puml");
|
||||
return lang == QStringLiteral("plantuml") ||
|
||||
lang == QStringLiteral("mermaid") ||
|
||||
lang == QStringLiteral("dot") ||
|
||||
lang == QStringLiteral("graphviz");
|
||||
}
|
||||
|
||||
QString ChatBlockModel::diagramPlaceholder(const QString &lang) const
|
||||
{
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
return QStringLiteral("<div style=\"padding:8px;border:1px dashed ") + defaultColor +
|
||||
QStringLiteral(";color:") + defaultColor +
|
||||
QStringLiteral(";font-style:italic;\">Loading ") +
|
||||
lang.toHtmlEscaped() + QStringLiteral(" diagram...</div>");
|
||||
return QStringLiteral("<div style=\"padding:8px;background:#f0f0f0;border:1px solid #ccc;\">")
|
||||
+ QStringLiteral("[Rendering ") + lang.toHtmlEscaped() + QStringLiteral(" diagram...]</div>");
|
||||
}
|
||||
|
||||
QString ChatBlockModel::diagramErrorHtml(const QString &error) const
|
||||
{
|
||||
return QStringLiteral("<div style=\"padding:8px;border:1px solid red;color:red;\">") +
|
||||
QStringLiteral("Diagram error: ") + error.toHtmlEscaped() +
|
||||
QStringLiteral("</div>");
|
||||
return QStringLiteral("<div style=\"padding:8px;background:#fee;border:1px solid #c00;color:#c00;\">")
|
||||
+ QStringLiteral("Diagram error: ") + error.toHtmlEscaped() + QStringLiteral("</div>");
|
||||
}
|
||||
|
||||
QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const
|
||||
{
|
||||
// Embed SVG inline as data URI
|
||||
const QString base64 = QString::fromLatin1(svg.toBase64());
|
||||
return QStringLiteral("<img src=\"data:image/svg+xml;base64,") + base64 +
|
||||
QStringLiteral("\" style=\"max-width:100%;\" />");
|
||||
return QStringLiteral("<img src=\"data:image/svg+xml;base64,")
|
||||
+ QString::fromLatin1(svg.toBase64())
|
||||
+ QStringLiteral("\" style=\"max-width:100%;\" />");
|
||||
}
|
||||
|
||||
QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const
|
||||
{
|
||||
// Embed PNG inline as data URI
|
||||
const QString base64 = QString::fromLatin1(png.toBase64());
|
||||
return QStringLiteral("<img src=\"data:image/png;base64,") + base64 +
|
||||
QStringLiteral("\" style=\"max-width:100%;\" />");
|
||||
return QStringLiteral("<img src=\"data:image/png;base64,")
|
||||
+ QString::fromLatin1(png.toBase64())
|
||||
+ QStringLiteral("\" style=\"max-width:100%;\" />");
|
||||
}
|
||||
|
||||
QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const
|
||||
{
|
||||
// Embed JPEG inline as data URI
|
||||
const QString base64 = QString::fromLatin1(jpeg.toBase64());
|
||||
return QStringLiteral("<img src=\"data:image/jpeg;base64,") + base64 +
|
||||
QStringLiteral("\" style=\"max-width:100%;\" />");
|
||||
return QStringLiteral("<img src=\"data:image/jpeg;base64,")
|
||||
+ QString::fromLatin1(jpeg.toBase64())
|
||||
+ QStringLiteral("\" style=\"max-width:100%;\" />");
|
||||
}
|
||||
|
||||
void ChatBlockModel::requestDiagram(const QString &lang, const QString &code, int blockIndex)
|
||||
{
|
||||
const QString requestKey = lang + QStringLiteral(":") +
|
||||
QString::fromLatin1(QCryptographicHash::hash(
|
||||
code.toUtf8(), QCryptographicHash::Md5).toHex());
|
||||
// Hash the request for deduplication
|
||||
const QString hashInput = lang + QStringLiteral(":") + code;
|
||||
const QString hash = QString::fromLatin1(
|
||||
QCryptographicHash::hash(hashInput.toUtf8(), QCryptographicHash::Sha256).toHex().left(16));
|
||||
|
||||
// Track duplicate requests only while this local render is in flight.
|
||||
if (!m_pendingDiagrams.contains(requestKey))
|
||||
m_pendingDiagrams[requestKey] = QList<int>();
|
||||
if (!m_pendingDiagrams[requestKey].contains(blockIndex))
|
||||
m_pendingDiagrams[requestKey].append(blockIndex);
|
||||
if (m_inFlightRequests.contains(requestKey))
|
||||
// Track which blocks want this diagram
|
||||
m_pendingDiagrams[hash].append(blockIndex);
|
||||
|
||||
// Don't re-request if already in flight
|
||||
if (m_inFlightRequests.contains(hash))
|
||||
return;
|
||||
m_inFlightRequests.insert(hash);
|
||||
|
||||
m_inFlightRequests.insert(requestKey);
|
||||
// Request from local diagram renderer (ollie-diagram service)
|
||||
QUrl url(QStringLiteral("http://localhost:18019/render"));
|
||||
QUrlQuery query;
|
||||
query.addQueryItem(QStringLiteral("lang"), lang);
|
||||
url.setQuery(query);
|
||||
|
||||
QNetworkRequest request;
|
||||
request.setAttribute(QNetworkRequest::User, requestKey);
|
||||
request.setTransferTimeout(10000);
|
||||
QNetworkRequest request(url);
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain"));
|
||||
request.setAttribute(QNetworkRequest::User, hash);
|
||||
|
||||
const QString langLower = lang.toLower();
|
||||
|
||||
if (langLower == QStringLiteral("plantuml") || langLower == QStringLiteral("puml")) {
|
||||
// PlantUML server accepts POST with raw source
|
||||
request.setUrl(QUrl(QStringLiteral("http://localhost:8080/svg")));
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain"));
|
||||
m_netManager->post(request, code.toUtf8());
|
||||
} else if (langLower == QStringLiteral("mermaid")) {
|
||||
// Mermaid-ink expects base64 in URL path, /img/ for PNG output
|
||||
// (SVG uses foreignObject which Qt's SVG renderer doesn't support)
|
||||
const QByteArray encoded = code.toUtf8().toBase64();
|
||||
request.setUrl(QUrl(QStringLiteral("http://localhost:8081/img/") + QString::fromLatin1(encoded)));
|
||||
m_netManager->get(request);
|
||||
} else {
|
||||
m_inFlightRequests.remove(requestKey);
|
||||
return;
|
||||
}
|
||||
m_netManager->post(request, code.toUtf8());
|
||||
}
|
||||
|
||||
void ChatBlockModel::onDiagramReply(QNetworkReply *reply)
|
||||
{
|
||||
reply->deleteLater();
|
||||
|
||||
const QString cacheKey = reply->request().attribute(QNetworkRequest::User).toString();
|
||||
m_inFlightRequests.remove(cacheKey);
|
||||
const QString hash = reply->request().attribute(QNetworkRequest::User).toString();
|
||||
m_inFlightRequests.remove(hash);
|
||||
|
||||
QByteArray imageData;
|
||||
bool success = false;
|
||||
QString errorMsg;
|
||||
QList<int> indices = m_pendingDiagrams.take(hash);
|
||||
if (indices.isEmpty())
|
||||
return;
|
||||
|
||||
if (reply->error() == QNetworkReply::NoError) {
|
||||
imageData = reply->readAll();
|
||||
QString resultHtml;
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
resultHtml = diagramErrorHtml(reply->errorString());
|
||||
} else {
|
||||
const QByteArray data = reply->readAll();
|
||||
const QString contentType = reply->header(QNetworkRequest::ContentTypeHeader).toString();
|
||||
|
||||
// Check for JPEG/PNG (mermaid) or SVG (plantuml)
|
||||
if (imageData.startsWith("\xff\xd8\xff")) {
|
||||
// JPEG image
|
||||
success = true;
|
||||
imageData.prepend("JPEG:");
|
||||
} else if (imageData.startsWith("\x89PNG")) {
|
||||
// PNG image
|
||||
success = true;
|
||||
imageData.prepend("PNG:");
|
||||
} else if (imageData.contains("<svg") || imageData.startsWith("<?xml")) {
|
||||
// SVG image
|
||||
success = true;
|
||||
imageData.prepend("SVG:");
|
||||
if (contentType.contains(QStringLiteral("svg"))) {
|
||||
resultHtml = diagramSvgHtml(data);
|
||||
} else if (contentType.contains(QStringLiteral("png"))) {
|
||||
resultHtml = diagramPngHtml(data);
|
||||
} else if (contentType.contains(QStringLiteral("jpeg")) || contentType.contains(QStringLiteral("jpg"))) {
|
||||
resultHtml = diagramJpegHtml(data);
|
||||
} else {
|
||||
errorMsg = QStringLiteral("Invalid response (not SVG, PNG, or JPEG)");
|
||||
resultHtml = diagramErrorHtml(QStringLiteral("Unknown content type: ") + contentType);
|
||||
}
|
||||
} else {
|
||||
errorMsg = reply->errorString();
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Keep the result only on the waiting segments; do not cache diagrams.
|
||||
} else {
|
||||
// The error is applied directly to waiting segments below.
|
||||
}
|
||||
|
||||
// Apply the response directly to waiting segments. Nothing is retained after
|
||||
// this request completes; a later occurrence renders through the local server.
|
||||
const QList<int> blocks = m_pendingDiagrams.take(cacheKey);
|
||||
for (int blockIndex : blocks) {
|
||||
if (blockIndex < 0 || blockIndex >= m_blocks.size())
|
||||
// Update all blocks waiting for this diagram
|
||||
for (int idx : indices) {
|
||||
if (idx < 0 || idx >= m_blocks.size())
|
||||
continue;
|
||||
|
||||
ChatBlock &block = m_blocks[blockIndex];
|
||||
BlockSegment *matchingSegment = nullptr;
|
||||
for (BlockSegment &segment : block.segments) {
|
||||
if (segment.type != BlockSegment::Diagram || !segment.diagramLoading)
|
||||
continue;
|
||||
const QString segmentKey = segment.diagramLang + QStringLiteral(":") +
|
||||
QString::fromLatin1(QCryptographicHash::hash(
|
||||
segment.diagramSource.toUtf8(), QCryptographicHash::Md5).toHex());
|
||||
if (segmentKey == cacheKey) {
|
||||
matchingSegment = &segment;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// A block can be rebuilt while a request is in flight. In that case
|
||||
// the segment still represents this request, but its source may have
|
||||
// been normalized by the parser. Use the sole pending diagram rather
|
||||
// than leaving the UI loading forever.
|
||||
if (!matchingSegment) {
|
||||
for (BlockSegment &segment : block.segments) {
|
||||
if (segment.type == BlockSegment::Diagram && segment.diagramLoading) {
|
||||
if (matchingSegment) {
|
||||
matchingSegment = nullptr;
|
||||
break;
|
||||
}
|
||||
matchingSegment = &segment;
|
||||
ChatBlock &block = m_blocks[idx];
|
||||
for (BlockSegment &seg : block.segments) {
|
||||
if (seg.type == BlockSegment::Diagram && seg.diagramLoading) {
|
||||
seg.diagramSvg = resultHtml;
|
||||
seg.diagramLoading = false;
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
seg.diagramError = reply->errorString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchingSegment)
|
||||
continue;
|
||||
|
||||
BlockSegment &segment = *matchingSegment;
|
||||
segment.diagramLoading = false;
|
||||
if (!success) {
|
||||
segment.diagramError = errorMsg;
|
||||
segment.html = diagramErrorHtml(errorMsg);
|
||||
} else if (imageData.startsWith("JPEG:")) {
|
||||
const QByteArray data = imageData.mid(5);
|
||||
segment.diagramSvg = QStringLiteral("data:image/jpeg;base64,") +
|
||||
QString::fromLatin1(data.toBase64());
|
||||
segment.html = diagramJpegHtml(data);
|
||||
} else if (imageData.startsWith("PNG:")) {
|
||||
const QByteArray data = imageData.mid(4);
|
||||
segment.diagramSvg = QStringLiteral("data:image/png;base64,") +
|
||||
QString::fromLatin1(data.toBase64());
|
||||
segment.html = diagramPngHtml(data);
|
||||
} else {
|
||||
const QByteArray data = imageData.startsWith("SVG:") ? imageData.mid(4) : imageData;
|
||||
segment.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
|
||||
QString::fromLatin1(data.toBase64());
|
||||
segment.html = diagramSvgHtml(data);
|
||||
}
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
QModelIndex idx = index(blockIndex);
|
||||
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
|
||||
|
||||
QModelIndex modelIdx = index(idx);
|
||||
emit dataChanged(modelIdx, modelIdx, {RenderedHtmlRole, SegmentsRole});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
#include <KSyntaxHighlighting/State>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QRegularExpression>
|
||||
#include <QHash>
|
||||
#include <QSet>
|
||||
#include <QVariantList>
|
||||
|
|
@ -31,7 +30,7 @@ struct BlockSegment {
|
|||
};
|
||||
|
||||
// Incremental rendering state for a block — tracks where we left off so
|
||||
// appendContent() can extend the last segment without reparsing from scratch.
|
||||
// content updates can extend the last segment without reparsing from scratch.
|
||||
struct BlockRenderState {
|
||||
bool inFence = false; // Currently inside a code fence
|
||||
QString fenceLang; // Language of the open fence
|
||||
|
|
@ -49,6 +48,7 @@ struct ChatBlock {
|
|||
enum Type {
|
||||
User,
|
||||
Assistant,
|
||||
Context,
|
||||
Reasoning,
|
||||
Call,
|
||||
Tool,
|
||||
|
|
@ -60,12 +60,14 @@ struct ChatBlock {
|
|||
};
|
||||
|
||||
Type type = Default;
|
||||
QString name;
|
||||
QString content;
|
||||
QString renderedHtml; // Legacy: full HTML (kept for compatibility)
|
||||
QString renderedHtml; // Full HTML render of content
|
||||
QList<BlockSegment> segments; // Structured segments for interactive rendering
|
||||
BlockRenderState renderState; // Incremental rendering state
|
||||
QString blockId; // Stable identifier (hash of type+name+first N chars of content)
|
||||
QString blockId; // Stable identifier (8-char hex from server)
|
||||
QString name; // For call blocks: tool name
|
||||
QString format; // For tool blocks: output format hint
|
||||
bool partial = false; // True if streaming (not final)
|
||||
};
|
||||
|
||||
class ChatBlockModel : public QAbstractListModel
|
||||
|
|
@ -74,19 +76,13 @@ class ChatBlockModel : public QAbstractListModel
|
|||
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
|
||||
|
||||
public:
|
||||
// Two-state parser for [[[blockname]]] ... [[[end]]] blocks.
|
||||
enum class State {
|
||||
BlockStart, // Waiting for a block header
|
||||
BlockEnd // Collecting content until [[[end]]]
|
||||
};
|
||||
|
||||
enum Roles {
|
||||
BlockTypeRole = Qt::UserRole + 1,
|
||||
BlockNameRole,
|
||||
ContentRole,
|
||||
RenderedHtmlRole,
|
||||
SegmentsRole, // QVariantList of segment maps
|
||||
BlockIdRole, // Stable block identifier
|
||||
PartialRole, // True if streaming (not final)
|
||||
};
|
||||
|
||||
explicit ChatBlockModel(QObject *parent = nullptr);
|
||||
|
|
@ -108,10 +104,8 @@ private slots:
|
|||
void onDiagramReply(QNetworkReply *reply);
|
||||
|
||||
private:
|
||||
void processLine(const QString &line);
|
||||
void appendContent(const QString &line);
|
||||
void processJsonLine(const QByteArray &line);
|
||||
void rebuildBlockHtml(int blockIndex);
|
||||
void incrementalAppend(int blockIndex, const QString &line);
|
||||
void renderLastSegmentPartial(int blockIndex, const QString &partialLine);
|
||||
static void rebuildRenderedHtmlFromSegments(ChatBlock &block);
|
||||
|
||||
|
|
@ -135,31 +129,19 @@ private:
|
|||
// Convert segments to QVariantList for QML
|
||||
QVariantList segmentsToVariant(const QList<BlockSegment> &segments) const;
|
||||
|
||||
State m_state = State::BlockStart;
|
||||
// Map block type string to enum
|
||||
static ChatBlock::Type blockTypeFromString(const QString &role);
|
||||
|
||||
KSyntaxHighlighting::Repository *m_repo = nullptr;
|
||||
QNetworkAccessManager *m_netManager = nullptr;
|
||||
|
||||
QList<ChatBlock> m_blocks;
|
||||
QString m_lineBuffer;
|
||||
quint64 m_blockIdCounter = 0; // Monotonic counter for stable block IDs
|
||||
|
||||
// Context blocks are injected into prompts but hidden from chat rendering.
|
||||
bool m_inContext = false;
|
||||
|
||||
// Code fence state (for streaming)
|
||||
bool m_inCodeFence = false;
|
||||
QString m_fenceLanguage;
|
||||
QString m_fenceContent;
|
||||
QByteArray m_lineBuffer; // Buffer for incomplete JSON lines
|
||||
|
||||
// Pending requests: hash -> block indices waiting for this diagram
|
||||
QHash<QString, QList<int>> m_pendingDiagrams;
|
||||
// Track which requests are in flight
|
||||
QSet<QString> m_inFlightRequests;
|
||||
|
||||
static const QRegularExpression s_headerRe;
|
||||
static const QRegularExpression s_endRe;
|
||||
static const QRegularExpression s_fenceOpenRe;
|
||||
static const QRegularExpression s_fenceCloseRe;
|
||||
};
|
||||
|
||||
#endif // CHATBLOCKMODEL_H
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
#include <QQmlContext>
|
||||
#include <QQuickStyle>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
|
||||
#include "ollie9pclient.h"
|
||||
#include "sessionmodel.h"
|
||||
|
|
@ -55,29 +54,6 @@ int main(int argc, char *argv[])
|
|||
QObject::connect(ollie, &Ollie9pClient::chatReceived,
|
||||
chatModel, &ChatBlockModel::appendData);
|
||||
|
||||
// Handle ollie:// URLs from plumber — fetch block and display in chat
|
||||
QObject::connect(plumber, &Plumber::ollieMessage, [ollie, chatModel](const QString &url) {
|
||||
// Parse ollie://session/agent#blockid
|
||||
QUrl parsed(url);
|
||||
if (parsed.scheme() != QStringLiteral("ollie"))
|
||||
return;
|
||||
QString path = parsed.path();
|
||||
if (path.startsWith('/'))
|
||||
path = path.mid(1);
|
||||
QStringList parts = path.split('/');
|
||||
if (parts.size() < 2)
|
||||
return;
|
||||
QString sessionId = parts.value(0);
|
||||
QString agentId = parts.value(1);
|
||||
QString blockId = parsed.fragment();
|
||||
if (blockId.isEmpty())
|
||||
return;
|
||||
|
||||
QString block = ollie->fetchBlock(sessionId, agentId, blockId);
|
||||
if (!block.isEmpty())
|
||||
chatModel->appendData(block);
|
||||
});
|
||||
|
||||
engine.rootContext()->setContextProperty("ollie", ollie);
|
||||
engine.rootContext()->setContextProperty("sessionModel", sessionModel);
|
||||
engine.rootContext()->setContextProperty("chatModel", chatModel);
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ ApplicationWindow {
|
|||
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
||||
|
||||
// Bookmarks model (non-persistent, GUI state only)
|
||||
// Each entry: { sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview }
|
||||
// Each entry: { sessionId, sessionName, agentId, agentName, blockId, blockType, preview }
|
||||
ListModel {
|
||||
id: bookmarksModel
|
||||
}
|
||||
|
||||
function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview) {
|
||||
function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, preview) {
|
||||
// Check for duplicate
|
||||
for (var i = 0; i < bookmarksModel.count; i++) {
|
||||
var b = bookmarksModel.get(i)
|
||||
|
|
@ -34,7 +34,6 @@ ApplicationWindow {
|
|||
agentName: agentName,
|
||||
blockId: blockId,
|
||||
blockType: blockType,
|
||||
blockName: blockName,
|
||||
preview: preview.substring(0, 80) + (preview.length > 80 ? "…" : "")
|
||||
})
|
||||
return true
|
||||
|
|
@ -74,7 +73,17 @@ ApplicationWindow {
|
|||
var agentName = match[2] ? decodeURIComponent(match[2]) : ""
|
||||
var blockId = match[3] || ""
|
||||
|
||||
// Find session by name and switch to it
|
||||
// If blockId is present, fetch and append the block content to current chat
|
||||
// Don't navigate — user wants to see the block in their current context
|
||||
if (blockId) {
|
||||
var block = ollie.fetchBlock(sessionName, agentName, blockId)
|
||||
if (block) {
|
||||
chatModel.appendData(block)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No blockId — navigate to session/agent
|
||||
var sessions = ollie.sessions
|
||||
for (var i = 0; i < sessions.length; i++) {
|
||||
var s = sessions[i]
|
||||
|
|
@ -92,13 +101,6 @@ ApplicationWindow {
|
|||
}
|
||||
}
|
||||
|
||||
// If blockId specified, scroll to it after a short delay
|
||||
if (blockId) {
|
||||
Qt.callLater(function() {
|
||||
chatPane.scrollToBlockId(blockId)
|
||||
})
|
||||
}
|
||||
|
||||
// Raise window
|
||||
root.show()
|
||||
root.raise()
|
||||
|
|
|
|||
|
|
@ -468,7 +468,8 @@ QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString
|
|||
if (sessionId.isEmpty() || agentId.isEmpty()) return {};
|
||||
if (!m_9p || !m_9p->isConnected()) return {};
|
||||
// Use immutable IDs — the 9P namespace resolves them via aliases.
|
||||
QString path = "session/" + sessionId + "/agent/" + agentId + "/log";
|
||||
// Read JSONL from log.raw for the chat model (not plain text log).
|
||||
QString path = "session/" + sessionId + "/agent/" + agentId + "/log.raw";
|
||||
return QString::fromUtf8(m_9p->read(path));
|
||||
}
|
||||
|
||||
|
|
@ -1075,13 +1076,20 @@ QVariantList Ollie9pClient::listAgentsInSession(const QString &sessionId)
|
|||
|
||||
QString Ollie9pClient::fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId)
|
||||
{
|
||||
if (sessionId.isEmpty() || agentId.isEmpty() || blockId.isEmpty())
|
||||
qDebug("fetchBlock: session=%s agent=%s block=%s", qPrintable(sessionId), qPrintable(agentId), qPrintable(blockId));
|
||||
if (sessionId.isEmpty() || agentId.isEmpty() || blockId.isEmpty()) {
|
||||
qDebug("fetchBlock: empty parameter");
|
||||
return QString();
|
||||
if (!m_9p || !m_9p->isConnected())
|
||||
}
|
||||
if (!m_9p || !m_9p->isConnected()) {
|
||||
qDebug("fetchBlock: not connected (m_9p=%p connected=%d)", (void*)m_9p, m_9p ? m_9p->isConnected() : 0);
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString path = QStringLiteral("session/%1/agent/%2/chat.search").arg(sessionId, agentId);
|
||||
QString path = QStringLiteral("session/%1/agent/%2/block").arg(sessionId, agentId);
|
||||
qDebug("fetchBlock: rdwr path=%s", qPrintable(path));
|
||||
QByteArray out = m_9p->rdwr(path, blockId.toUtf8());
|
||||
qDebug("fetchBlock: got %lld bytes", (long long)out.size());
|
||||
return QString::fromUtf8(out);
|
||||
}
|
||||
|
||||
|
|
@ -1124,7 +1132,7 @@ void Ollie9pClient::startActiveAgentStreams()
|
|||
if (!m_daemonConnected) return;
|
||||
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return;
|
||||
|
||||
m_chat->start(agentPath() + "/chat.raw");
|
||||
m_chat->start(agentPath() + "/log.raw");
|
||||
|
||||
// Check for pending bypass request on this session
|
||||
checkPendingBypass();
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ public:
|
|||
Q_INVOKABLE bool removePeer(const QString &sessionId, const QString &agentId, const QString &peerName);
|
||||
Q_INVOKABLE QVariantList listAgentsInSession(const QString &sessionId);
|
||||
|
||||
// Block lookup via chat.search
|
||||
// Block lookup via block file (rdwr: write ID, read JSON)
|
||||
Q_INVOKABLE QString fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId);
|
||||
|
||||
signals:
|
||||
|
|
|
|||
Loading…
Reference in New Issue