server-side block IDs for stable bookmark/plumb targets
- Add blockCounter to Agent, generating sequential hex IDs - Include #blockId in all block headers: [[[role:name#id]]] - GUI parses server-provided IDs from headers - Falls back to client-generated IDs for legacy blocks without server IDs - Block IDs are now deterministic based on chat history order
This commit is contained in:
parent
233a5cc2e9
commit
46aa901763
|
|
@ -76,6 +76,7 @@ type Agent struct {
|
||||||
chatSignalMu sync.Mutex
|
chatSignalMu sync.Mutex
|
||||||
chatSignalCh chan struct{} // closed on new chat data; replaced with fresh channel
|
chatSignalCh chan struct{} // closed on new chat data; replaced with fresh channel
|
||||||
plan []byte // agent's plan file contents
|
plan []byte // agent's plan file contents
|
||||||
|
blockCounter uint64 // monotonic counter for generating stable block IDs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend returns the active backend from the runtime.
|
// Backend returns the active backend from the runtime.
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,20 @@ package agent
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Chat log methods ---
|
// --- Chat log methods ---
|
||||||
|
|
||||||
const maxChatLogBytes = 16 * 1024 * 1024
|
const maxChatLogBytes = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
// NextBlockID returns the next block ID and increments the counter.
|
||||||
|
// Block IDs are sequential integers formatted as hex strings.
|
||||||
|
func (ag *Agent) NextBlockID() string {
|
||||||
|
id := atomic.AddUint64(&ag.blockCounter, 1) - 1
|
||||||
|
return fmt.Sprintf("%x", id)
|
||||||
|
}
|
||||||
|
|
||||||
// AppendChat appends data to the chat log and notifies stream readers.
|
// AppendChat appends data to the chat log and notifies stream readers.
|
||||||
func (ag *Agent) AppendChat(data []byte) {
|
func (ag *Agent) AppendChat(data []byte) {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"ollie/cmd/olliesrv/internal/backend"
|
"ollie/cmd/olliesrv/internal/backend"
|
||||||
"ollie/format"
|
"ollie/format"
|
||||||
)
|
)
|
||||||
|
|
@ -17,6 +19,7 @@ func (ag *Agent) initChatHandler() {
|
||||||
var streamingRole string
|
var streamingRole string
|
||||||
var streamingResponseID string
|
var streamingResponseID string
|
||||||
var streamingFence string
|
var streamingFence string
|
||||||
|
var streamingBlockID string
|
||||||
|
|
||||||
ag.output = func(ev Event) {
|
ag.output = func(ev Event) {
|
||||||
switch ev.Role {
|
switch ev.Role {
|
||||||
|
|
@ -29,15 +32,18 @@ func (ag *Agent) initChatHandler() {
|
||||||
}
|
}
|
||||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||||
}
|
}
|
||||||
|
blockID := ag.NextBlockID()
|
||||||
if ev.Role == "assistant" && ev.ResponseID != "" {
|
if ev.Role == "assistant" && ev.ResponseID != "" {
|
||||||
ag.AppendChat([]byte(format.AssistantHeaderPrefix + ev.ResponseID + "]]]\n"))
|
// Include both responseID and blockID: [[[assistant:responseID#blockID]]]
|
||||||
|
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", ev.ResponseID, blockID)))
|
||||||
streamingResponseID = ev.ResponseID
|
streamingResponseID = ev.ResponseID
|
||||||
} else {
|
} else {
|
||||||
ag.AppendChat([]byte(format.RoleDelim(ev.Role) + "\n"))
|
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID)))
|
||||||
}
|
}
|
||||||
streamingRole = ev.Role
|
streamingRole = ev.Role
|
||||||
|
streamingBlockID = blockID
|
||||||
}
|
}
|
||||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat))
|
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
|
||||||
|
|
||||||
case "reasoning":
|
case "reasoning":
|
||||||
if streamingRole != ev.Role {
|
if streamingRole != ev.Role {
|
||||||
|
|
@ -48,10 +54,12 @@ func (ag *Agent) initChatHandler() {
|
||||||
}
|
}
|
||||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||||
}
|
}
|
||||||
ag.AppendChat([]byte(format.RoleDelim(ev.Role) + "\n"))
|
blockID := ag.NextBlockID()
|
||||||
|
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n", blockID)))
|
||||||
streamingRole = ev.Role
|
streamingRole = ev.Role
|
||||||
|
streamingBlockID = blockID
|
||||||
}
|
}
|
||||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat))
|
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
|
||||||
|
|
||||||
case "tool":
|
case "tool":
|
||||||
if streamingRole == "tool" {
|
if streamingRole == "tool" {
|
||||||
|
|
@ -76,11 +84,15 @@ func (ag *Agent) initChatHandler() {
|
||||||
streamingRole = ""
|
streamingRole = ""
|
||||||
}
|
}
|
||||||
if ev.Content == "" {
|
if ev.Content == "" {
|
||||||
ag.AppendChat([]byte(format.ToolDelim(ev.Name) + "```" + ev.OutputFormat + "\n"))
|
// 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
|
streamingFence = format.ToolFence
|
||||||
streamingRole = "tool"
|
streamingRole = "tool"
|
||||||
|
streamingBlockID = blockID
|
||||||
} else {
|
} else {
|
||||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat))
|
// Complete tool output in one event
|
||||||
|
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
|
||||||
}
|
}
|
||||||
|
|
||||||
case "retry":
|
case "retry":
|
||||||
|
|
@ -92,9 +104,11 @@ func (ag *Agent) initChatHandler() {
|
||||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||||
streamingRole = ""
|
streamingRole = ""
|
||||||
}
|
}
|
||||||
ag.AppendChat([]byte(format.RetryTag + "\n"))
|
blockID := ag.NextBlockID()
|
||||||
|
ag.AppendChat([]byte(fmt.Sprintf("[[[retry#%s]]]\n", blockID)))
|
||||||
streamingRole = "retry"
|
streamingRole = "retry"
|
||||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat))
|
streamingBlockID = blockID
|
||||||
|
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
|
||||||
|
|
||||||
case "call":
|
case "call":
|
||||||
if streamingRole != "" {
|
if streamingRole != "" {
|
||||||
|
|
@ -105,7 +119,7 @@ func (ag *Agent) initChatHandler() {
|
||||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||||
streamingRole = ""
|
streamingRole = ""
|
||||||
}
|
}
|
||||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat))
|
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if streamingRole != "" {
|
if streamingRole != "" {
|
||||||
|
|
@ -116,8 +130,9 @@ func (ag *Agent) initChatHandler() {
|
||||||
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
|
||||||
streamingRole = ""
|
streamingRole = ""
|
||||||
}
|
}
|
||||||
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat))
|
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
|
||||||
}
|
}
|
||||||
|
_ = streamingBlockID // suppress unused warning (available for future use)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,29 +161,31 @@ func (ag *Agent) ReplayMessages(messages []backend.Message) {
|
||||||
switch m.Role {
|
switch m.Role {
|
||||||
case "system":
|
case "system":
|
||||||
case "user":
|
case "user":
|
||||||
ag.AppendChat(format.FormatEvent("user", "", m.Content, ""))
|
ag.AppendChat(format.FormatEvent("user", "", m.Content, "", ag.NextBlockID()))
|
||||||
case "assistant":
|
case "assistant":
|
||||||
if m.Reasoning != "" {
|
if m.Reasoning != "" {
|
||||||
ag.AppendChat([]byte(format.RoleDelim("reasoning") + "\n" + m.Reasoning + "\n" + format.EndTag + "\n"))
|
blockID := ag.NextBlockID()
|
||||||
|
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n%s\n%s\n", blockID, m.Reasoning, format.EndTag)))
|
||||||
}
|
}
|
||||||
|
blockID := ag.NextBlockID()
|
||||||
if m.ID != "" {
|
if m.ID != "" {
|
||||||
ag.AppendChat([]byte(format.AssistantHeaderPrefix + m.ID + "]]]\n"))
|
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", m.ID, blockID)))
|
||||||
} else {
|
} else {
|
||||||
ag.AppendChat([]byte(format.AssistantTag + "\n"))
|
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID)))
|
||||||
}
|
}
|
||||||
if m.Content != "" {
|
if m.Content != "" {
|
||||||
ag.AppendChat([]byte(m.Content + "\n"))
|
ag.AppendChat([]byte(m.Content + "\n"))
|
||||||
}
|
}
|
||||||
ag.AppendChat([]byte(format.EndTag + "\n"))
|
ag.AppendChat([]byte(format.EndTag + "\n"))
|
||||||
for _, tc := range m.ToolCalls {
|
for _, tc := range m.ToolCalls {
|
||||||
ag.AppendChat(format.FormatEvent("call", tc.Name, string(tc.Arguments), ""))
|
ag.AppendChat(format.FormatEvent("call", tc.Name, string(tc.Arguments), "", ag.NextBlockID()))
|
||||||
}
|
}
|
||||||
case "tool":
|
case "tool":
|
||||||
name := callNames[m.ToolCallID]
|
name := callNames[m.ToolCallID]
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = m.ToolCallID
|
name = m.ToolCallID
|
||||||
}
|
}
|
||||||
ag.AppendChat(format.FormatEvent("tool", name, m.Content, ""))
|
ag.AppendChat(format.FormatEvent("tool", name, m.Content, "", ag.NextBlockID()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
package format
|
package format
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormatEvent converts event data to bytes for appending to a chat log.
|
// FormatEvent converts event data to bytes for appending to a chat log.
|
||||||
// Streaming roles (assistant, reasoning, retry) return only the content chunk;
|
// Streaming roles (assistant, reasoning, retry) return only the content chunk;
|
||||||
// the caller is responsible for writing the [[[role]]] header for those.
|
// the caller is responsible for writing the [[[role]]] header for those.
|
||||||
func FormatEvent(role, name, content, outputFormat string) []byte {
|
// If blockID is non-empty, it is included in the block header.
|
||||||
|
func FormatEvent(role, name, content, outputFormat, blockID string) []byte {
|
||||||
switch role {
|
switch role {
|
||||||
case "user":
|
case "user":
|
||||||
|
if blockID != "" {
|
||||||
|
return []byte(fmt.Sprintf("[[[user#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
|
||||||
|
}
|
||||||
return []byte(UserTag + "\n" + content + "\n" + EndTag + "\n")
|
return []byte(UserTag + "\n" + content + "\n" + EndTag + "\n")
|
||||||
case "assistant":
|
case "assistant":
|
||||||
return []byte(content)
|
return []byte(content)
|
||||||
|
|
@ -17,26 +22,44 @@ func FormatEvent(role, name, content, outputFormat string) []byte {
|
||||||
return []byte(content)
|
return []byte(content)
|
||||||
case "call":
|
case "call":
|
||||||
args := strings.Join(strings.Fields(content), " ")
|
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")
|
return []byte(CallDelim(name) + "```\n" + args + "\n```\n" + EndTag + "\n")
|
||||||
case "tool":
|
case "tool":
|
||||||
fence := "```"
|
fence := "```"
|
||||||
if outputFormat != "" {
|
if outputFormat != "" {
|
||||||
fence += 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")
|
return []byte(ToolDelim(name) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n")
|
||||||
case "error":
|
case "error":
|
||||||
role := "error"
|
r := "error"
|
||||||
if name != "" {
|
if name != "" {
|
||||||
role = "error:" + name
|
r = "error:" + name
|
||||||
}
|
}
|
||||||
return []byte(RoleDelim(role) + "\n" + content + "\n" + EndTag + "\n")
|
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":
|
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")
|
return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n")
|
||||||
case "retry":
|
case "retry":
|
||||||
return []byte(content)
|
return []byte(content)
|
||||||
case "stalled":
|
case "stalled":
|
||||||
|
if blockID != "" {
|
||||||
|
return []byte(fmt.Sprintf("[[[stalled#%s]]]\n", blockID) + EndTag + "\n")
|
||||||
|
}
|
||||||
return []byte(RoleDelim("stalled") + "\n" + EndTag + "\n")
|
return []byte(RoleDelim("stalled") + "\n" + EndTag + "\n")
|
||||||
case "maxsteps":
|
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")
|
return []byte(RoleDelim("maxsteps") + "\n" + content + "\n" + EndTag + "\n")
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import (
|
||||||
|
|
||||||
// Formatting constants for the 9P chat log protocol.
|
// Formatting constants for the 9P chat log protocol.
|
||||||
// Used by fs.NewEventHandler and fs.replayMessagesToLog.
|
// 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 (
|
const (
|
||||||
// EndTag marks the end of a message block.
|
// EndTag marks the end of a message block.
|
||||||
|
|
@ -55,13 +58,23 @@ func CallDelim(name string) string {
|
||||||
return fmt.Sprintf(CallDelimPrefix+"%s]]]\n", name)
|
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.
|
// BlockHeader is a single-line [[[...]]] marker that opens a log block.
|
||||||
type BlockHeader struct {
|
type BlockHeader struct {
|
||||||
Role string // e.g. "user", "assistant", "tool", "retry", or "tool:shell"
|
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
|
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]]]"
|
// ParseBlockHeader extracts a BlockHeader from a line like "[[[tool:shell#a1b2]]]"
|
||||||
// or "[[[assistant]]]". Returns nil if the line is not a valid block header.
|
// or "[[[assistant]]]". Returns nil if the line is not a valid block header.
|
||||||
func ParseBlockHeader(line string) *BlockHeader {
|
func ParseBlockHeader(line string) *BlockHeader {
|
||||||
const prefix, suffix = "[[[", "]]]"
|
const prefix, suffix = "[[[", "]]]"
|
||||||
|
|
@ -74,6 +87,13 @@ func ParseBlockHeader(line string) *BlockHeader {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
bh := &BlockHeader{Role: inner}
|
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 {
|
if idx := strings.Index(inner, ":"); idx >= 0 {
|
||||||
bh.Role = inner[:idx]
|
bh.Role = inner[:idx]
|
||||||
bh.Name = inner[idx+1:]
|
bh.Name = inner[idx+1:]
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,14 @@ Item {
|
||||||
chatView.positionViewAtIndex(idx, ListView.Center)
|
chatView.positionViewAtIndex(idx, ListView.Center)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Block not in current view — reload full chat and try again
|
// Block not in current view — it may have been trimmed.
|
||||||
|
// Server-side block IDs are deterministic, so reload full chat.
|
||||||
if (ollie.activeSessionId && ollie.activeAgentId) {
|
if (ollie.activeSessionId && ollie.activeAgentId) {
|
||||||
chatModel.reset()
|
chatModel.reset()
|
||||||
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
|
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
|
||||||
if (log) {
|
if (log) {
|
||||||
chatModel.appendData(log)
|
chatModel.appendData(log)
|
||||||
// Don't trim — keep full history so we can find the block
|
// Don't trim — the bookmark target may be in older history
|
||||||
idx = chatModel.indexForBlockId(blockId)
|
idx = chatModel.indexForBlockId(blockId)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
chatView.positionViewAtIndex(idx, ListView.Center)
|
chatView.positionViewAtIndex(idx, ListView.Center)
|
||||||
|
|
|
||||||
|
|
@ -813,19 +813,27 @@ void ChatBlockModel::processLine(const QString &line)
|
||||||
{
|
{
|
||||||
switch (m_state) {
|
switch (m_state) {
|
||||||
case State::BlockStart: {
|
case State::BlockStart: {
|
||||||
// [[[blockname]]] starts a new block
|
// [[[blockname]]] or [[[blockname#blockId]]] starts a new block
|
||||||
QRegularExpressionMatch match = s_headerRe.match(line);
|
QRegularExpressionMatch match = s_headerRe.match(line);
|
||||||
if (match.hasMatch()) {
|
if (match.hasMatch()) {
|
||||||
const QString blockName = match.captured(1);
|
QString blockName = match.captured(1);
|
||||||
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
|
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
|
||||||
ChatBlock newBlock;
|
ChatBlock newBlock;
|
||||||
newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
|
|
||||||
newBlock.name = blockName.section(':', 1);
|
// Extract blockId if present (after #)
|
||||||
// Generate stable block ID: sequential counter + type + name hash
|
int hashIdx = blockName.lastIndexOf('#');
|
||||||
// The counter ensures uniqueness even for identical type/name pairs
|
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;
|
QString idSource = QString::number(m_blockIdCounter++) + blockName;
|
||||||
newBlock.blockId = QString::fromLatin1(
|
newBlock.blockId = QString::fromLatin1(
|
||||||
QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12));
|
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);
|
m_blocks.append(newBlock);
|
||||||
endInsertRows();
|
endInsertRows();
|
||||||
emit countChanged();
|
emit countChanged();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue