Why: Uploaded images never reached the model, so a text-only chat model could be asked to read a picture it cannot see. Evidence: go test ./internal/... passed locally before this commit. Image-link rejection is covered by vision package tests. Impact: Current-turn attachments and fetched image links become image input. History keeps a text placeholder. Image turns swap to the configured vision model when the chat model is not in the capable list, and fallbacks are filtered the same way. Refs: Agent-System-Dev feature/four-model-vision Co-authored-by: Cursor <cursoragent@cursor.com>
590 lines
20 KiB
Go
590 lines
20 KiB
Go
// Package ctxasm 实现上下文组装与预算管理(4.10):分层管线 + 可观测裁剪记录。
|
||
package ctxasm
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
agentllm "agent-native/runtime/llm"
|
||
"agent-system-backend/internal/vision"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// Report 记录一次组装的实际构成(可观测:模型看到了什么、裁了什么)。
|
||
type Report struct {
|
||
Items []string `json:"items"`
|
||
Truncations []string `json:"truncations,omitempty"`
|
||
EstChars int `json:"estChars"`
|
||
Sections []Section `json:"sections,omitempty"`
|
||
PrefixHash string `json:"prefixHash,omitempty"`
|
||
}
|
||
|
||
// DefaultMaxAttachmentChars 单个附件注入模型的默认字符上限(settings context.maxAttachmentChars 可调)。
|
||
// 16k 时代码值对会议纪要类长文档明显不足(正文大半被静默丢弃);48k ≈ 主流 128k 上下文的安全占比。
|
||
const DefaultMaxAttachmentChars = 48000
|
||
|
||
// DefaultMaxCardChars 单张产物卡回填注入的默认字符上限(settings context.maxCardChars 可调)。
|
||
const DefaultMaxCardChars = 12000
|
||
|
||
// CardResolver 按 ID 取收件箱卡(产物卡全文回填;nil = 无卡服务/无此卡,跳过)。
|
||
type CardResolver func(id core.ID) *core.ActivityCard
|
||
|
||
// ArtifactResolver 按 ID 取工件及其内容(artifact_ref 正文回填;D46 与卡片回填
|
||
// 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。
|
||
type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool)
|
||
|
||
// AssembleOptions 是组装的可选行为。零值全部关闭(向后兼容)。
|
||
type AssembleOptions struct {
|
||
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
|
||
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
|
||
ThinkReplay bool
|
||
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
|
||
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
|
||
Surfaces []core.SurfaceSnapshot
|
||
}
|
||
|
||
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
|
||
// 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。
|
||
// prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。
|
||
// resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威
|
||
// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填
|
||
// artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。
|
||
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver, opts AssembleOptions) ([]agentllm.Message, *Report) {
|
||
rep := &Report{}
|
||
msgs := make([]agentllm.Message, 0, len(sys)+1)
|
||
msgs = append(msgs, sys...)
|
||
rep.Items = append(rep.Items, promptObs...)
|
||
if strings.TrimSpace(prof.SystemPrompt) != "" {
|
||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
|
||
rep.Items = append(rep.Items, "system(画像自定义段)")
|
||
}
|
||
if len(opts.Surfaces) > 0 {
|
||
var b strings.Builder
|
||
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
|
||
for _, snap := range opts.Surfaces {
|
||
b.WriteString(projectSurface(snap))
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
|
||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
|
||
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
|
||
}
|
||
|
||
maxHist := prof.Context.MaxHistoryMessages
|
||
if maxHist <= 0 {
|
||
maxHist = int(eff.GetNumber("context.maxHistoryMessages", 40))
|
||
}
|
||
if maxHist <= 0 {
|
||
maxHist = 40
|
||
}
|
||
history = visibleHistory(history)
|
||
// 历史窗口(不含当前轮——当前轮单独注入附件与检索)。
|
||
start := 0
|
||
if len(history) > maxHist {
|
||
start = len(history) - maxHist
|
||
if prof.Context.SummarizeBeyond {
|
||
summary := extractiveSummary(history[:start])
|
||
if summary != "" {
|
||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: "[更早对话摘要]\n" + summary})
|
||
rep.Items = append(rep.Items, fmt.Sprintf("summary(%d)", start))
|
||
}
|
||
}
|
||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("历史 %d 条超窗口 %d,仅保留最近部分", len(history), maxHist))
|
||
}
|
||
limitCard := int(eff.GetNumber("context.maxCardChars", DefaultMaxCardChars))
|
||
if limitCard <= 0 {
|
||
limitCard = DefaultMaxCardChars
|
||
}
|
||
cardsBackfilled := 0
|
||
artsBackfilled := 0
|
||
for _, m := range history[start:] {
|
||
if m.ID == currentUser.ID {
|
||
continue
|
||
}
|
||
if m.Role == core.RoleAssistant {
|
||
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled, opts)...)
|
||
continue
|
||
}
|
||
content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)
|
||
if content == "" {
|
||
continue
|
||
}
|
||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: content})
|
||
}
|
||
rep.Items = append(rep.Items, fmt.Sprintf("history(%d)", len(history)-start))
|
||
if cardsBackfilled > 0 {
|
||
rep.Items = append(rep.Items, fmt.Sprintf("card(%d)", cardsBackfilled))
|
||
}
|
||
if artsBackfilled > 0 {
|
||
rep.Items = append(rep.Items, fmt.Sprintf("artifact(%d)", artsBackfilled))
|
||
}
|
||
|
||
// 当前轮:文本 + 附件解析结果 + 知识检索注入。
|
||
msgs = append(msgs, BuildCurrentUser(eff, currentUser, attachments, knowledge, rep))
|
||
|
||
fillEstimate(msgs, rep)
|
||
return msgs, rep
|
||
}
|
||
|
||
// BuildCurrentUser 组装当前轮的用户消息(文本部件 + 附件解析全文 + 知识检索)。
|
||
// 会话转录为权威上下文时,每轮只构建这一条消息,历史由转录承载——
|
||
// 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。
|
||
func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message {
|
||
var sb strings.Builder
|
||
var images []agentllm.Image
|
||
for _, p := range currentUser.Parts {
|
||
if p.Type == core.PartText {
|
||
sb.WriteString(p.Text)
|
||
}
|
||
}
|
||
if len(attachments) > 0 {
|
||
limit := int(eff.GetNumber("context.maxAttachmentChars", DefaultMaxAttachmentChars))
|
||
if limit <= 0 {
|
||
limit = DefaultMaxAttachmentChars
|
||
}
|
||
sb.WriteString("\n\n[附件](用户上传文档的解析全文,按原文顺序保留标题/列表/表格;超长时文末标注截断。请依据文档内容完成任务,输出完整不省略)\n")
|
||
for _, doc := range attachments {
|
||
if pics := vision.ImagesFromDoc(doc); len(pics) > 0 {
|
||
kept, notes := vision.Admit(images, pics)
|
||
images = append(images, kept...)
|
||
if len(kept) > 0 {
|
||
fmt.Fprintf(&sb, "### %s\n[图片 %s,图像已随本消息发送]\n", doc.Meta.Filename, doc.Meta.Filename)
|
||
} else {
|
||
fmt.Fprintf(&sb, "### %s\n[图片 %s,本轮未发送图像]\n", doc.Meta.Filename, doc.Meta.Filename)
|
||
}
|
||
for _, note := range notes {
|
||
fmt.Fprintf(&sb, "%s\n", note)
|
||
if rep != nil {
|
||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s:%s", doc.Meta.Filename, note))
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
text := doc.PlainText()
|
||
if runes := []rune(text); len(runes) > limit {
|
||
text = string(runes[:limit]) + "…(附件超长截断)"
|
||
if rep != nil {
|
||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
|
||
}
|
||
}
|
||
fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text)
|
||
}
|
||
if rep != nil {
|
||
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
|
||
}
|
||
}
|
||
if len(knowledge) > 0 {
|
||
sb.WriteString("\n\n[相关知识]\n")
|
||
for i, h := range knowledge {
|
||
fmt.Fprintf(&sb, "[%d] %s(来源 %s)\n%s\n", i+1, h.Doc.Title, h.Doc.Source, h.Doc.Chunk)
|
||
}
|
||
if rep != nil {
|
||
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
|
||
}
|
||
}
|
||
return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String(), Images: images}
|
||
}
|
||
|
||
// maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。
|
||
const maxToolArgRunes = 4000
|
||
|
||
// forgedMarkerLine 匹配模型在正文里模仿出来的调用/结果/工件行。回放时整行剔除,
|
||
// 避免下一轮把这种文本当成「以前就是这样调用工具」的格式。
|
||
var forgedMarkerLine = regexp.MustCompile(`(?m)^[^\n]*\[(?:调用工具|工具结果|产出工件) [^\n]*\n?`)
|
||
|
||
// renderMessage 把用户消息部件渲染为模型可读文本(思考部件不回灌;
|
||
// card 引用回填产物卡正文、artifact_ref 回填工件正文——截断记入 rep.Truncations)。
|
||
// 助手消息不走这里:工具调用必须保持提供方的原生消息,见 renderAssistantTurns。
|
||
func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) string {
|
||
var sb strings.Builder
|
||
for _, p := range m.Parts {
|
||
switch p.Type {
|
||
case core.PartText:
|
||
sb.WriteString(stripForgedMarkers(p.Text))
|
||
sb.WriteString("\n")
|
||
case core.PartArtifactRef:
|
||
sb.WriteString(renderArtifactRef(p.Artifact, resolveArtifact, limitCard, rep, artsBackfilled))
|
||
case core.PartHITL:
|
||
if p.Hitl != nil && p.Hitl.Resolved {
|
||
fmt.Fprintf(&sb, "[用户确认:%s → %s]\n", p.Hitl.Question, p.Hitl.Answer)
|
||
}
|
||
case core.PartCard:
|
||
sb.WriteString(renderCardPart(p.Card, resolveCard, limitCard, rep, cardsBackfilled))
|
||
}
|
||
}
|
||
return strings.TrimSpace(sb.String())
|
||
}
|
||
|
||
// renderAssistantTurns 把一条助手消息切成提供方可接受的若干段:
|
||
// 前置文本与 tool_calls 组成一条 assistant,每个调用紧跟一条 tool 结果。
|
||
// 没有结果的调用补一条失败占位,避免模型把中断当成成功。孤立的结果直接跳过。
|
||
// 被守卫降级、且后面不是工具调用的过程文本不回放。
|
||
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int, opts AssembleOptions) []agentllm.Message {
|
||
var out []agentllm.Message
|
||
var text strings.Builder
|
||
var think strings.Builder
|
||
var calls []agentllm.ToolCall
|
||
results := map[string]core.ToolResultPart{}
|
||
seq := 0
|
||
|
||
flush := func() {
|
||
content := strings.TrimSpace(text.String())
|
||
thinkText := strings.TrimSpace(think.String())
|
||
if opts.ThinkReplay && thinkText != "" {
|
||
// 交错思考模型要求历史 assistant 原样带 <think>;只进请求,不落盘。
|
||
content = "<think>\n" + thinkText + "\n</think>\n\n" + content
|
||
}
|
||
if content == "" && len(calls) == 0 {
|
||
text.Reset()
|
||
think.Reset()
|
||
results = map[string]core.ToolResultPart{}
|
||
return
|
||
}
|
||
out = append(out, agentllm.Message{Role: agentllm.RoleAssistant, Content: content, ToolCalls: calls})
|
||
for _, c := range calls {
|
||
body := "(无结果:运行中断)"
|
||
isErr := true
|
||
if res, ok := results[c.ID]; ok {
|
||
body = res.Summary
|
||
isErr = res.Status == "error"
|
||
}
|
||
out = append(out, agentllm.Message{
|
||
Role: agentllm.RoleTool,
|
||
ToolCallID: c.ID,
|
||
Name: c.Name,
|
||
Content: body,
|
||
IsError: isErr,
|
||
})
|
||
}
|
||
text.Reset()
|
||
think.Reset()
|
||
calls = nil
|
||
results = map[string]core.ToolResultPart{}
|
||
}
|
||
|
||
for i, p := range m.Parts {
|
||
switch p.Type {
|
||
case core.PartThinking:
|
||
if strings.TrimSpace(p.Text) != "" {
|
||
think.WriteString(p.Text)
|
||
think.WriteString("\n")
|
||
}
|
||
case core.PartText:
|
||
if demotedProcessText(m.Parts, i) {
|
||
continue
|
||
}
|
||
cleaned := stripForgedMarkers(p.Text)
|
||
if strings.TrimSpace(cleaned) == "" {
|
||
continue
|
||
}
|
||
if len(calls) > 0 {
|
||
flush()
|
||
}
|
||
text.WriteString(cleaned)
|
||
text.WriteString("\n")
|
||
case core.PartToolCall:
|
||
if p.ToolCall == nil {
|
||
continue
|
||
}
|
||
id := p.ToolCall.ID
|
||
if id == "" {
|
||
id = fmt.Sprintf("hist_%s_%d", m.ID, seq)
|
||
seq++
|
||
}
|
||
calls = append(calls, agentllm.ToolCall{
|
||
ID: id,
|
||
Name: p.ToolCall.Name,
|
||
Arguments: compactToolArguments(p.ToolCall.Arguments),
|
||
})
|
||
case core.PartToolResult:
|
||
if p.ToolResult == nil {
|
||
continue
|
||
}
|
||
id := p.ToolResult.CallID
|
||
if id == "" {
|
||
for _, c := range calls {
|
||
if _, ok := results[c.ID]; !ok {
|
||
id = c.ID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if id == "" || !hasCall(calls, id) {
|
||
continue
|
||
}
|
||
results[id] = *p.ToolResult
|
||
case core.PartArtifactRef:
|
||
if len(calls) > 0 {
|
||
flush()
|
||
}
|
||
text.WriteString(renderArtifactRef(p.Artifact, resolveArtifact, limitCard, rep, artsBackfilled))
|
||
case core.PartHITL:
|
||
if p.Hitl == nil || !p.Hitl.Resolved {
|
||
continue
|
||
}
|
||
if len(calls) > 0 {
|
||
flush()
|
||
}
|
||
fmt.Fprintf(&text, "[用户确认:%s → %s]\n", p.Hitl.Question, p.Hitl.Answer)
|
||
case core.PartCard:
|
||
if len(calls) > 0 {
|
||
flush()
|
||
}
|
||
text.WriteString(renderCardPart(p.Card, resolveCard, limitCard, rep, cardsBackfilled))
|
||
}
|
||
}
|
||
flush()
|
||
return out
|
||
}
|
||
|
||
func hasCall(calls []agentllm.ToolCall, id string) bool {
|
||
for _, c := range calls {
|
||
if c.ID == id {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// demotedProcessText 是守卫降级后的谎称正文:过程文本,且后面紧跟的不是工具调用。
|
||
// 工具轮的解说也标了过程,但它后面就是 tool_call,要保留。
|
||
func demotedProcessText(parts []core.Part, i int) bool {
|
||
if parts[i].Type != core.PartText || !parts[i].Process {
|
||
return false
|
||
}
|
||
return i+1 >= len(parts) || parts[i+1].Type != core.PartToolCall
|
||
}
|
||
|
||
func stripForgedMarkers(text string) string {
|
||
return strings.TrimSpace(forgedMarkerLine.ReplaceAllString(text, ""))
|
||
}
|
||
|
||
// compactToolArguments 在参数超限时截断其中的长字符串。解析失败或截断后仍超限时,
|
||
// 换成仍可被提供方接受的占位对象,而不是一段被截断的非法 JSON。
|
||
func compactToolArguments(raw string) string {
|
||
if utf8.RuneCountInString(raw) <= maxToolArgRunes {
|
||
return raw
|
||
}
|
||
var v any
|
||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||
return fmt.Sprintf(`{"_omitted":"参数 %d 字符"}`, utf8.RuneCountInString(raw))
|
||
}
|
||
truncateLongStrings(&v, 500)
|
||
encoded, err := json.Marshal(v)
|
||
if err != nil || utf8.RuneCountInString(string(encoded)) > maxToolArgRunes {
|
||
return fmt.Sprintf(`{"_omitted":"参数 %d 字符"}`, utf8.RuneCountInString(raw))
|
||
}
|
||
return string(encoded)
|
||
}
|
||
|
||
func truncateLongStrings(v *any, keep int) {
|
||
switch x := (*v).(type) {
|
||
case map[string]any:
|
||
for k, val := range x {
|
||
truncateLongStrings(&val, keep)
|
||
x[k] = val
|
||
}
|
||
case []any:
|
||
for i := range x {
|
||
truncateLongStrings(&x[i], keep)
|
||
}
|
||
case string:
|
||
n := utf8.RuneCountInString(x)
|
||
if n > keep {
|
||
*v = string([]rune(x)[:keep]) + fmt.Sprintf("…(已省略 %d 字符)", n-keep)
|
||
}
|
||
}
|
||
}
|
||
|
||
// renderCardPart 渲染 card 部件:引用部件(cardId)回填产物卡全文(超限截断留痕);
|
||
// 无 resolver 或无此卡时留占位(可见即信号,不静默丢弃)。
|
||
func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep *Report, cardsBackfilled *int) string {
|
||
if cp == nil {
|
||
return ""
|
||
}
|
||
if cp.CardID == "" || resolveCard == nil {
|
||
return fmt.Sprintf("[卡片 %s(内容见收件箱)]\n", cp.Kind)
|
||
}
|
||
card := resolveCard(cp.CardID)
|
||
if card == nil {
|
||
return fmt.Sprintf("[卡片 %s(内容见收件箱)]\n", cp.Kind)
|
||
}
|
||
*cardsBackfilled++
|
||
var sb strings.Builder
|
||
fmt.Fprintf(&sb, "[产物卡片:%s]\n", card.Title)
|
||
total := 0
|
||
for _, n := range card.Body {
|
||
switch n.Type {
|
||
case "markdown":
|
||
runes := []rune(n.Markdown)
|
||
if total+len(runes) > limit {
|
||
keep := limit - total
|
||
if keep < 0 {
|
||
keep = 0
|
||
}
|
||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("卡片 %s 超 %d 字符截断", card.Title, limit))
|
||
sb.WriteString(string(runes[:keep]))
|
||
sb.WriteString("…(卡片超长截断)\n")
|
||
return sb.String()
|
||
}
|
||
total += len(runes)
|
||
sb.WriteString(n.Markdown)
|
||
sb.WriteString("\n")
|
||
case "kv":
|
||
for _, kv := range n.KV {
|
||
fmt.Fprintf(&sb, "%s:%s\n", kv[0], kv[1])
|
||
}
|
||
}
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时
|
||
// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行
|
||
// (可见即信号,不静默丢弃)。
|
||
// 占位文案不带「[产出工件 …]」方括号形态:那正是 ClaimGate 用来识别伪造调用
|
||
// 标记的模式——历史里自己产出这种格式,下一轮模型照抄就会被终轮检查误判为
|
||
// 「正文里出现了工具调用标记」。
|
||
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
|
||
if a == nil {
|
||
return ""
|
||
}
|
||
name := a.Name
|
||
if isImageArtifact(a.Kind, name) {
|
||
return fmt.Sprintf("此前上传的图片:%s。\n", name)
|
||
}
|
||
if resolve == nil {
|
||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||
}
|
||
art, content, ok := resolve(a.ArtifactID)
|
||
if !ok {
|
||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||
}
|
||
if !utf8.Valid(content) {
|
||
return fmt.Sprintf("此前已生成文件:%s(二进制内容,正文见工件查看器)。\n", art.Name)
|
||
}
|
||
*backfilled++
|
||
text := string(content)
|
||
if runes := []rune(text); len(runes) > limit {
|
||
text = string(runes[:limit]) + "…(工件超长截断)"
|
||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit))
|
||
}
|
||
return fmt.Sprintf("此前已生成文件 %s,内容如下:\n%s\n", art.Name, text)
|
||
}
|
||
|
||
func isImageArtifact(kind, name string) bool {
|
||
if kind == "image" {
|
||
return true
|
||
}
|
||
switch strings.ToLower(filepath.Ext(name)) {
|
||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。
|
||
func extractiveSummary(dropped []core.Message) string {
|
||
var lines []string
|
||
for _, m := range dropped {
|
||
var text string
|
||
for _, p := range m.Parts {
|
||
if p.Type == core.PartText && strings.TrimSpace(p.Text) != "" {
|
||
text = strings.TrimSpace(p.Text)
|
||
break
|
||
}
|
||
}
|
||
if text == "" {
|
||
continue
|
||
}
|
||
runes := []rune(text)
|
||
if len(runes) > 80 {
|
||
text = string(runes[:80]) + "…"
|
||
}
|
||
role := "用户"
|
||
if m.Role == core.RoleAssistant {
|
||
role = "助手"
|
||
}
|
||
lines = append(lines, role+":"+text)
|
||
if len(lines) >= 12 {
|
||
break
|
||
}
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// InjectMemory 把未过期记忆作为系统段放到提示词之后。空列表不改。
|
||
func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm.Message {
|
||
if len(entries) == 0 {
|
||
return sys
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("[记忆]\n")
|
||
n := 0
|
||
for _, e := range entries {
|
||
if e.ExpiresAt != nil && e.ExpiresAt.Before(time.Now()) {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(e.Content) == "" {
|
||
continue
|
||
}
|
||
fmt.Fprintf(&b, "- (%s) %s\n", e.Type, strings.TrimSpace(e.Content))
|
||
n++
|
||
if n >= 20 {
|
||
break
|
||
}
|
||
}
|
||
if n == 0 {
|
||
return sys
|
||
}
|
||
out := make([]agentllm.Message, 0, len(sys)+1)
|
||
out = append(out, sys...)
|
||
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
|
||
return out
|
||
}
|
||
|
||
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
|
||
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
|
||
func projectSurface(snap core.SurfaceSnapshot) string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
|
||
if snap.SemanticText != "" {
|
||
fmt.Fprintf(&b, ":%s", snap.SemanticText)
|
||
}
|
||
b.WriteString("\n 结构:")
|
||
var walk func(n core.UINode)
|
||
first := true
|
||
walk = func(n core.UINode) {
|
||
label := string(n.Type)
|
||
if n.Title != "" {
|
||
label += "(\"" + n.Title + "\")"
|
||
}
|
||
if n.ID != "" {
|
||
label += "#" + n.ID
|
||
}
|
||
if len(n.Source) > 0 {
|
||
label += "[" + strings.Join(n.Source, ",") + "]"
|
||
}
|
||
if first {
|
||
b.WriteString(label)
|
||
first = false
|
||
} else {
|
||
b.WriteString(" > " + label)
|
||
}
|
||
for _, c := range n.Children {
|
||
walk(c)
|
||
}
|
||
}
|
||
for _, n := range snap.Spec.Nodes {
|
||
walk(n)
|
||
}
|
||
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
|
||
return b.String()
|
||
}
|