Skip to content

Commit 6931861

Browse files
committed
feat(chat): save answers to context pack
1 parent 3267372 commit 6931861

4 files changed

Lines changed: 126 additions & 9 deletions

File tree

apps/api/routers/chat.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ def _context_pack_local_file_summary(context: dict | None) -> list[dict]:
3434
return summary
3535

3636

37+
def _context_pack_saved_note_summary(context: dict | None) -> list[dict]:
38+
context_pack = (context or {}).get("context_pack") or {}
39+
notes = context_pack.get("saved_notes") or []
40+
summary = []
41+
for item in notes[:8]:
42+
if not isinstance(item, dict):
43+
continue
44+
content = str(item.get("content") or "")
45+
summary.append({
46+
"id": str(item.get("id") or "")[:90],
47+
"title": str(item.get("title") or "回答摘录")[:120],
48+
"source": str(item.get("source") or "")[:40],
49+
"trace_id": str(item.get("trace_id") or "")[:90],
50+
"content_chars": len(content),
51+
})
52+
return summary
53+
54+
3755
@router.post("/query")
3856
def query(payload: ChatQuery, request: Request, user: dict = Depends(get_current_user)):
3957
check_rate_limit(f"chat:{user['id']}", settings.chat_rate_limit_per_minute)
@@ -69,6 +87,15 @@ def query(payload: ChatQuery, request: Request, user: dict = Depends(get_current
6987
{"local_files_count": len(local_file_summary), "has_local_file_content": True},
7088
{"local_files": local_file_summary, "temporary_chat": temporary_chat},
7189
)
90+
saved_note_summary = _context_pack_saved_note_summary(payload.context)
91+
if saved_note_summary:
92+
trace_service.add_step(
93+
trace_id,
94+
"context_pack",
95+
"saved_note_context",
96+
{"saved_notes_count": len(saved_note_summary), "has_saved_note_content": True},
97+
{"saved_notes": saved_note_summary, "temporary_chat": temporary_chat},
98+
)
7299
start = time.time()
73100
try:
74101
output = adapters.call_adapter(agent, version, payload.message, trace_id, payload.context, user=user)

apps/api/services/adapters.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ def _safe_json(value: Any) -> str:
3131
return json.dumps({"unserializable": True}, ensure_ascii=False)
3232

3333

34-
def _redact_local_file_content(value: Any) -> Any:
34+
def _redact_context_pack_content(value: Any) -> Any:
3535
if isinstance(value, list):
36-
return [_redact_local_file_content(item) for item in value]
36+
return [_redact_context_pack_content(item) for item in value]
3737
if not isinstance(value, dict):
3838
return value
39-
redacted = {key: _redact_local_file_content(item) for key, item in value.items()}
39+
redacted = {key: _redact_context_pack_content(item) for key, item in value.items()}
4040
context_pack = redacted.get("context_pack")
4141
if isinstance(context_pack, dict) and isinstance(context_pack.get("local_files"), list):
4242
safe_files = []
@@ -49,6 +49,17 @@ def _redact_local_file_content(value: Any) -> Any:
4949
safe_item["content_redacted"] = bool(content)
5050
safe_files.append(safe_item)
5151
context_pack["local_files"] = safe_files
52+
if isinstance(context_pack, dict) and isinstance(context_pack.get("saved_notes"), list):
53+
safe_notes = []
54+
for item in context_pack["saved_notes"]:
55+
if not isinstance(item, dict):
56+
continue
57+
safe_item = dict(item)
58+
content = str(safe_item.pop("content", "") or "")
59+
safe_item["content_chars"] = len(content)
60+
safe_item["content_redacted"] = bool(content)
61+
safe_notes.append(safe_item)
62+
context_pack["saved_notes"] = safe_notes
5263
return redacted
5364

5465

@@ -430,7 +441,7 @@ def call_generic_http(adapter: dict[str, Any], payload: dict[str, Any], trace_id
430441
raise HTTPException(status_code=502, detail=f"External agent call failed: {exc}")
431442
finally:
432443
duration = int((time.time() - start) * 1000)
433-
safe_payload = _redact_local_file_content(payload)
444+
safe_payload = _redact_context_pack_content(payload)
434445
db.insert("tool_calls", {"id": db.new_id("call"), "trace_id": trace_id, "adapter_id": adapter["id"], "request_json": _safe_json(safe_payload), "response_json": _safe_json(response_json), "status": status, "duration_ms": duration, "created_at": db.now()})
435446
trace_service.add_step(trace_id, "tool_call", "generic_http", safe_payload, {"endpoint": endpoint, "duration_ms": duration, "status": status})
436447
return normalize_agent_result(response_json, trace_id)

apps/web/static/app.js

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const CONTEXT_PACK_PRESETS_STORAGE_KEY = 'dap_context_pack_presets_v1';
5252
const CONTEXT_PACK_FILE_LIMIT = 4;
5353
const CONTEXT_PACK_FILE_CHAR_LIMIT = 12000;
5454
const CONTEXT_PACK_FILE_BYTES_LIMIT = 262144;
55+
const CONTEXT_PACK_NOTE_LIMIT = 8;
56+
const CONTEXT_PACK_NOTE_CHAR_LIMIT = 4000;
5557
let contextPack = loadContextPack();
5658
let contextPackPresets = loadContextPackPresets();
5759
let activeContextPackPresetId = '';
@@ -152,7 +154,7 @@ function compactTags(values,limit=4){
152154
return list.length?list.map(v=>tag(v)).join(''):'<span class="muted">暂无标签</span>';
153155
}
154156
function defaultContextPack(){
155-
return {name:'默认工作包',instructions:'',agentId:'',datasetIds:[],knowledgeBaseIds:[],reportIds:[],traceIds:[],sessionId:'',sessionIds:[],localFiles:[],toolMode:'auto',evidenceDepth:'standard',memoryMode:'project',includeCanvas:false,updatedAt:''};
157+
return {name:'默认工作包',instructions:'',agentId:'',datasetIds:[],knowledgeBaseIds:[],reportIds:[],traceIds:[],sessionId:'',sessionIds:[],localFiles:[],savedNotes:[],toolMode:'auto',evidenceDepth:'standard',memoryMode:'project',includeCanvas:false,updatedAt:''};
156158
}
157159
function normalizeIdList(list,limit=6){
158160
return [...new Set(asList(list).map(v=>String(v||'').trim()).filter(Boolean))].slice(0,limit);
@@ -174,6 +176,26 @@ function normalizeContextFile(file={}){
174176
function normalizeContextFiles(files){
175177
return (Array.isArray(files)?files:[]).map(normalizeContextFile).filter(f=>f.name&&f.content).slice(0,CONTEXT_PACK_FILE_LIMIT);
176178
}
179+
function contextPackNoteId(title='note'){
180+
const stem=String(title||'note').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,30)||'note';
181+
return `note_${stem}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,6)}`;
182+
}
183+
function normalizeContextNote(note={}){
184+
const title=short(String(note.title||note.question||'回答摘录').trim()||'回答摘录',80);
185+
return {
186+
id:String(note.id||contextPackNoteId(title)).slice(0,90),
187+
title,
188+
source:short(String(note.source||'chat_answer').trim()||'chat_answer',40),
189+
question:short(String(note.question||'').trim(),220),
190+
content:String(note.content||'').slice(0,CONTEXT_PACK_NOTE_CHAR_LIMIT),
191+
traceId:String(note.traceId||note.trace_id||'').slice(0,90),
192+
sessionId:String(note.sessionId||note.session_id||'').slice(0,90),
193+
createdAt:String(note.createdAt||note.created_at||new Date().toISOString()).slice(0,40)
194+
};
195+
}
196+
function normalizeContextNotes(notes){
197+
return (Array.isArray(notes)?notes:[]).map(normalizeContextNote).filter(note=>note.title&&note.content).slice(0,CONTEXT_PACK_NOTE_LIMIT);
198+
}
177199
function normalizeContextPack(pack={}){
178200
const base=defaultContextPack();
179201
const next=Object.assign({},base,pack||{});
@@ -188,6 +210,7 @@ function normalizeContextPack(pack={}){
188210
next.sessionIds=normalizeIdList([next.sessionId,...asList(next.sessionIds)],8);
189211
next.sessionId=String(next.sessionId||next.sessionIds[0]||'').slice(0,90);
190212
next.localFiles=normalizeContextFiles(next.localFiles);
213+
next.savedNotes=normalizeContextNotes(next.savedNotes);
191214
next.toolMode=['auto','analysis','sql','codex'].includes(next.toolMode)?next.toolMode:'auto';
192215
next.evidenceDepth=['standard','full'].includes(next.evidenceDepth)?next.evidenceDepth:'standard';
193216
next.memoryMode=['default','project'].includes(next.memoryMode)?next.memoryMode:'project';
@@ -231,7 +254,7 @@ function persistContextPackPresets(){
231254
}
232255
function contextPackHasContent(pack=contextPack){
233256
const p=normalizeContextPack(pack);
234-
return Boolean(p.instructions.trim()||p.agentId||p.datasetIds.length||p.knowledgeBaseIds.length||p.reportIds.length||p.traceIds.length||p.sessionIds.length||p.localFiles.length||(p.includeCanvas&&chatCanvasValue().trim()));
257+
return Boolean(p.instructions.trim()||p.agentId||p.datasetIds.length||p.knowledgeBaseIds.length||p.reportIds.length||p.traceIds.length||p.sessionIds.length||p.localFiles.length||p.savedNotes.length||(p.includeCanvas&&chatCanvasValue().trim()));
235258
}
236259
function asList(value){
237260
if(Array.isArray(value)) return value;
@@ -868,6 +891,45 @@ function writeAnswerToCanvas(key,mode='replace'){
868891
setChatCanvasDraft(next,status,{reason:status});
869892
focusChatCanvas();
870893
}
894+
function savedAnswerNoteMarkdown(r,meta={}){
895+
const lines=[
896+
`# 回答摘录:${answerQuestion(meta)}`,
897+
'',
898+
`- Trace:${r?.trace_id||meta.trace_id||'未返回 Trace'}`,
899+
`- 会话:${meta.session_id||activeSessionId||'当前会话'}`,
900+
'',
901+
'## 回答',
902+
r?.answer||'暂无回答正文'
903+
];
904+
if(r?.report_markdown) lines.push('', '## 报告草稿', r.report_markdown);
905+
if((r?.warnings||[]).length) lines.push('', '## 注意事项', ...(r.warnings||[]).map(w=>`- ${w}`));
906+
if((r?.next_actions||[]).length) lines.push('', '## 下一步', ...(r.next_actions||[]).map(a=>`- ${a}`));
907+
return lines.join('\n').slice(0,CONTEXT_PACK_NOTE_CHAR_LIMIT);
908+
}
909+
function saveAnswerToContextPack(key,btn){
910+
const cached=answerDraftCache[key];
911+
if(!cached) return toast('回答上下文已失效,请重新打开会话');
912+
const {r,meta}=cached;
913+
setBusy(btn,true);
914+
try{
915+
const note=normalizeContextNote({
916+
title:answerQuestion(meta),
917+
source:'chat_answer',
918+
question:answerQuestion(meta),
919+
content:savedAnswerNoteMarkdown(r,meta),
920+
traceId:r?.trace_id||meta.trace_id||'',
921+
sessionId:meta.session_id||activeSessionId||'',
922+
createdAt:new Date().toISOString()
923+
});
924+
contextPack.savedNotes=normalizeContextNotes([note,...normalizeContextPack(contextPack).savedNotes]);
925+
if(note.traceId) contextPack.traceIds=normalizeIdList([note.traceId,...contextPack.traceIds],6);
926+
if(note.sessionId) addSessionToContextPack(note.sessionId);
927+
contextPack.updatedAt=new Date().toISOString();
928+
persistContextPack({toast:'回答已存入工作包'});
929+
}finally{
930+
setBusy(btn,false);
931+
}
932+
}
871933
function reportEvidenceFromAnswer(r,meta={}){
872934
return [{
873935
type:'chat_answer',
@@ -921,6 +983,7 @@ function answerContextActions(r, meta={}, key=''){
921983
`<button class="answer-tool" data-prompt="${esc(research)}" onclick="setAnalysisDraft(this.dataset.prompt,'agent_business_analysis')">转深度研究</button>`,
922984
`<button class="answer-tool" data-title="优化问数工作流" data-prompt="${esc(codexPrompt)}" onclick="setCodexDraft(this.dataset.title,this.dataset.prompt)">创建 Codex 任务</button>`,
923985
key?`<button class="answer-tool" onclick="saveAnswerAsReport('${jsArg(key)}',this)">保存报告</button>`:'',
986+
key?`<button class="answer-tool" onclick="saveAnswerToContextPack('${jsArg(key)}',this)">存入工作包</button>`:'',
924987
traceId?`<button class="answer-tool" onclick="openEvidence('${jsArg(traceId)}','steps')">定位证据</button>`:'',
925988
traceId?`<button class="answer-tool" onclick="addTraceToContextPack('${jsArg(traceId)}',this)">加入工作包</button>`:'',
926989
`<button class="answer-tool ghost-tool" data-copy="${esc(copyText)}" onclick="copyAnswerText(this.dataset.copy,this)">复制</button>`
@@ -1948,13 +2011,14 @@ function contextPackCounts(){
19482011
traces:p.traceIds.length,
19492012
sessions:p.sessionIds.length,
19502013
files:p.localFiles.length,
2014+
notes:p.savedNotes.length,
19512015
instructions:p.instructions.trim()?1:0,
19522016
canvas:p.includeCanvas&&chatCanvasValue().trim()?1:0
19532017
};
19542018
}
19552019
function contextPackSummaryLabel(){
19562020
const counts=contextPackCounts();
1957-
const total=counts.datasets+counts.knowledge+counts.reports+counts.traces+counts.sessions+counts.files+counts.instructions+counts.canvas+(contextPack.agentId?1:0);
2021+
const total=counts.datasets+counts.knowledge+counts.reports+counts.traces+counts.sessions+counts.files+counts.notes+counts.instructions+counts.canvas+(contextPack.agentId?1:0);
19582022
return total?`${total} 项上下文`:'未捕获';
19592023
}
19602024
function contextPackSummaryDetail(){
@@ -1968,6 +2032,7 @@ function contextPackSummaryDetail(){
19682032
if(counts.reports) parts.push(`${counts.reports} 报告`);
19692033
if(counts.traces) parts.push(`${counts.traces} Trace`);
19702034
if(counts.files) parts.push(`${counts.files} 本地资料`);
2035+
if(counts.notes) parts.push(`${counts.notes} 回答摘录`);
19712036
if(counts.canvas) parts.push('Canvas');
19722037
if(counts.sessions) parts.push(`${counts.sessions} 会话`);
19732038
return parts.length?parts.join(' · '):'Project-style local context';
@@ -1989,10 +2054,11 @@ function contextPackPills(){
19892054
p.traceIds.forEach(id=>pills.push({kind:'trace',id,label:'Trace',value:id}));
19902055
p.sessionIds.forEach(id=>pills.push({kind:'session',id,label:'会话',value:id}));
19912056
p.localFiles.forEach(file=>pills.push({kind:'file',id:file.id,label:'本地资料',value:`${file.name} · ${fileSizeLabel(file.size)}`}));
2057+
p.savedNotes.forEach(note=>pills.push({kind:'note',id:note.id,label:'回答摘录',value:note.traceId?`${note.title} · ${note.traceId}`:note.title}));
19922058
return pills.length?`<div class="context-pack-pills">${pills.map(pill=>`<article class="context-pack-pill">
19932059
<div><small>${esc(pill.label)}</small><b>${esc(short(pill.value,34))}</b></div>
19942060
<nav aria-label="${esc(pill.label)} 操作"><button type="button" onclick="openContextPackItem('${jsArg(pill.kind)}','${jsArg(pill.id)}')">打开</button><button type="button" onclick="removeContextPackItem('${jsArg(pill.kind)}','${jsArg(pill.id)}')">移除</button></nav>
1995-
</article>`).join('')}</div>`:`<p class="context-pack-empty">捕获当前会话、添加资产或加入本地资料后,这里会显示 Agent、数据集、知识库、Trace、报告、会话和资料线索。</p>`;
2061+
</article>`).join('')}</div>`:`<p class="context-pack-empty">捕获当前会话、添加资产、加入本地资料或存入关键回答后,这里会显示 Agent、数据集、知识库、Trace、报告、会话、资料和摘录线索。</p>`;
19962062
}
19972063
function contextPackStatusText(){
19982064
return contextPack.updatedAt?`已更新 ${timeText(contextPack.updatedAt)} · 本地浏览器工作包`:'本地浏览器工作包 · 未写入服务端';
@@ -2241,6 +2307,7 @@ function removeContextPackItem(kind,id){
22412307
if(kind==='report') contextPack.reportIds=removeId(contextPack.reportIds);
22422308
if(kind==='trace') contextPack.traceIds=removeId(contextPack.traceIds);
22432309
if(kind==='file') contextPack.localFiles=normalizeContextFiles(contextPack.localFiles.filter(file=>file.id!==id));
2310+
if(kind==='note') contextPack.savedNotes=normalizeContextNotes(contextPack.savedNotes.filter(note=>note.id!==id));
22442311
if(kind==='session'){
22452312
contextPack.sessionIds=removeId(asList(contextPack.sessionIds));
22462313
contextPack.sessionId=contextPack.sessionIds[0]||'';
@@ -2261,6 +2328,12 @@ function openContextPackItem(kind,id){
22612328
showPage('chat');
22622329
setTimeout(()=>setChatCanvasDraft(`# 本地资料:${file.name}\n\n${file.content}`,'本地资料已写入 Canvas'),120);
22632330
}
2331+
if(kind==='note'){
2332+
const note=normalizeContextPack(contextPack).savedNotes.find(item=>item.id===id);
2333+
if(!note) return toast('回答摘录不存在');
2334+
showPage('chat');
2335+
setTimeout(()=>setChatCanvasDraft(note.content,'回答摘录已写入 Canvas'),120);
2336+
}
22642337
}
22652338
function addTraceToContextPack(traceId,btn){
22662339
if(!traceId) return;
@@ -2292,6 +2365,10 @@ function contextPackPrompt(){
22922365
lines.push('', '本地资料:');
22932366
p.localFiles.forEach(file=>lines.push(`- ${file.name} (${fileSizeLabel(file.size)}):${short(file.content.replace(/\s+/g,' '),900)}`));
22942367
}
2368+
if(p.savedNotes.length){
2369+
lines.push('', '回答摘录:');
2370+
p.savedNotes.forEach(note=>lines.push(`- ${note.title}${note.traceId?` (${note.traceId})`:''}${short(note.content.replace(/\s+/g,' '),900)}`));
2371+
}
22952372
if(p.traceIds.length) lines.push(`Trace:${p.traceIds.join('、')}`);
22962373
if(p.sessionIds.length) lines.push(`来源会话:${p.sessionIds.join('、')}`);
22972374
if(p.includeCanvas&&chatCanvasValue().trim()) lines.push(`Canvas 草稿:${short(chatCanvasValue().trim().replace(/\s+/g,' '),520)}`);
@@ -2308,6 +2385,7 @@ function contextPackCanvasMarkdown(){
23082385
if(p.knowledgeBaseIds.length) lines.push('## 知识库',...p.knowledgeBaseIds.map(id=>`- ${knowledgeBaseName(id)} (${id})`),'');
23092386
if(p.reportIds.length) lines.push('## 报告',...p.reportIds.map(id=>`- ${contextPackReportTitle(id)} (${id})`),'');
23102387
if(p.localFiles.length) lines.push('## 本地资料',...p.localFiles.flatMap(file=>[`### ${file.name}`,`大小:${fileSizeLabel(file.size)};类型:${file.type}`,'',file.content.slice(0,3000),'']),'');
2388+
if(p.savedNotes.length) lines.push('## 回答摘录',...p.savedNotes.flatMap(note=>[`### ${note.title}`,`来源:${note.source}${note.traceId?`;Trace:${note.traceId}`:''}${note.sessionId?`;会话:${note.sessionId}`:''}`,'',note.content.slice(0,3000),'']),'');
23112389
if(p.traceIds.length) lines.push('## Trace',...p.traceIds.map(id=>`- ${id}`),'');
23122390
if(p.sessionIds.length) lines.push('## 来源会话',...p.sessionIds.map(id=>`- ${id}`),'');
23132391
if(p.includeCanvas&&chatCanvasValue().trim()) lines.push('## Canvas 草稿',chatCanvasValue().trim().slice(0,3000),'');
@@ -2323,6 +2401,7 @@ function contextPackPayload(){
23232401
knowledge_base_ids:p.knowledgeBaseIds,
23242402
report_ids:p.reportIds,
23252403
local_files:p.localFiles.map(file=>({id:file.id,name:file.name,type:file.type,size:file.size,content:file.content.slice(0,6000),created_at:file.createdAt})),
2404+
saved_notes:p.savedNotes.map(note=>({id:note.id,title:note.title,source:note.source,question:note.question,content:note.content.slice(0,3000),trace_id:note.traceId,session_id:note.sessionId,created_at:note.createdAt})),
23262405
trace_ids:p.traceIds,
23272406
session_id:p.sessionId||null,
23282407
session_ids:p.sessionIds,

0 commit comments

Comments
 (0)