@yolk_vat-y/dsh-project-memory 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +43 -5
- package/README.zh-CN.md +44 -7
- package/client/client.js +1439 -464
- package/client/client.js.map +1 -1
- package/package.json +2 -2
- package/src/auto-inject.js +214 -0
- package/src/client/MemoryView.tsx +349 -0
- package/src/client/TaskCommandNode.tsx +41 -30
- package/src/client/TaskComponents.tsx +388 -0
- package/src/client/TaskPanel.module.css +225 -40
- package/src/client/TaskPanel.tsx +174 -391
- package/src/client/client.ts +4 -4
- package/src/client/locales.ts +74 -0
- package/src/client/task-data-store.ts +152 -0
- package/src/client/task-hooks.ts +169 -0
- package/src/client/task-ui-store.ts +118 -0
- package/src/commands/insight-actions.js +390 -0
- package/src/commands/task-actions.js +17 -7
- package/src/commands/tasks.js +6 -3
- package/src/global-seed.js +41 -0
- package/src/index.js +42 -7
- package/src/insight-store.js +558 -0
- package/src/project-profile.js +78 -0
- package/src/reflection-pipeline.js +146 -0
- package/src/similarity.js +52 -0
- package/src/store.js +73 -0
- package/src/tools/lesson-tools.js +101 -0
- package/src/tools/task-tools.js +31 -2
- package/src/types.js +56 -0
- package/src/client/task-store.ts +0 -192
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// PR 2 静默注入引擎(entry 常驻块 + relevance 门控)
|
|
2
|
+
// 安全约定:
|
|
3
|
+
// - 纯函数 buildInjection 可单测;
|
|
4
|
+
// - wrapLlmStream 仅在 resolver 给出项目 root 时才会真正动 messages,
|
|
5
|
+
// 任何异常/禁用/无 root → 原样透传(零副作用),绝不让宿主请求受影响。
|
|
6
|
+
// - “静默注入”文本带 [Memory Inject] 前缀,落在最后一条 user message 内容块里;
|
|
7
|
+
// 指纹去重保证内容未变不重复追加(变更由宿主消息历史自然保留到压缩前)。
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
10
|
+
import { ProjectMemoryStore } from './store.js'
|
|
11
|
+
import { memoryRootFor } from './util/fs.js'
|
|
12
|
+
import { insightMatchText } from './similarity.js'
|
|
13
|
+
import { normalizedTokenOverlap } from './similarity.js'
|
|
14
|
+
import { cfgInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
|
|
15
|
+
import { projectTags } from './project-profile.js'
|
|
16
|
+
|
|
17
|
+
export const INJECT_MARK = '[Memory Inject]'
|
|
18
|
+
|
|
19
|
+
/** 注入引擎配置:insight 默认之上叠加 autoContext 预算/门控参数。 */
|
|
20
|
+
export function cfgEngine(config) {
|
|
21
|
+
const c = (config && config.autoContext) || {}
|
|
22
|
+
const base = cfgInsight(config)
|
|
23
|
+
return {
|
|
24
|
+
...base,
|
|
25
|
+
maxTokens: typeof c.maxTokens === 'number' ? c.maxTokens : 400,
|
|
26
|
+
entryMaxInsights: typeof c.entryMaxInsights === 'number' ? c.entryMaxInsights : 6,
|
|
27
|
+
relevanceMin: typeof c.relevanceMin === 'number' ? c.relevanceMin : 0.25,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function textOf(message) {
|
|
32
|
+
const blocks = (message && message.content) || []
|
|
33
|
+
return blocks
|
|
34
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
35
|
+
.map((b) => b.text)
|
|
36
|
+
.join('\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lastUserText(messages) {
|
|
40
|
+
if (!Array.isArray(messages)) return ''
|
|
41
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
42
|
+
const m = messages[i]
|
|
43
|
+
if (m && m.role === 'user') {
|
|
44
|
+
const t = textOf(m)
|
|
45
|
+
if (t.trim()) return t.trim()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fingerprint(text) {
|
|
52
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 12)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function linesOf(list) {
|
|
56
|
+
const out = []
|
|
57
|
+
for (const it of list || []) {
|
|
58
|
+
if (!it || it.archived || it.draft) continue
|
|
59
|
+
const body = it.fix || it.solution || it.choice || (Array.isArray(it.steps) ? `步骤 ${it.steps.length} 条` : '')
|
|
60
|
+
out.push(`- [${it.id}] ${it.kind}: ${it.title}${body ? ` — ${String(body).slice(0, 120)}` : ''}`)
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 任务卡 + 非草稿任务级 insights 摘要(常驻块主体)。 */
|
|
66
|
+
export function buildEntryContent(task, cfg) {
|
|
67
|
+
if (!task) return ''
|
|
68
|
+
const steps = (task.steps || []).map((s) => (typeof s === 'string' ? s : s.content || s.text || '').slice(0, 80))
|
|
69
|
+
const card = [`任务: ${task.title || '(untitled)'}`, `进度: ${steps.filter((s) => true).length ? `${steps.length} 步` : ''}`, ...steps.slice(0, 12).map((s, i) => ` ${i + 1}. ${s}`)]
|
|
70
|
+
const insights = linesOf(task.insights)
|
|
71
|
+
const parts = [...card]
|
|
72
|
+
if (insights.length) parts.push(`任务记忆:`, ...insights.slice(0, cfg.entryMaxInsights || 6))
|
|
73
|
+
return parts.join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 构建注入内容(不写盘、无副作用)。
|
|
78
|
+
* @param {object} opts { query, task, store, globalStore, projectTagsList, cfg }
|
|
79
|
+
* @returns {{ text: string, labels: string[] }}
|
|
80
|
+
*/
|
|
81
|
+
export function buildInjection(opts) {
|
|
82
|
+
const { query, task, globalStore, cfg } = opts
|
|
83
|
+
const store = opts.store || null
|
|
84
|
+
const tags = opts.projectTagsList || []
|
|
85
|
+
const labels = []
|
|
86
|
+
const parts = []
|
|
87
|
+
let budgetChars = (cfg.maxTokens || 400) * 3
|
|
88
|
+
|
|
89
|
+
// B) relevance:procedure(trigger 命中)全步骤优先
|
|
90
|
+
const gItems = globalStore ? globalStore.items() : []
|
|
91
|
+
const matched = []
|
|
92
|
+
for (const it of gItems) {
|
|
93
|
+
if (!it || it.archived || it.kind !== 'procedure' || !it.trigger) continue
|
|
94
|
+
const tr = it.trigger
|
|
95
|
+
const kwHit = (tr.keywords || []).some((k) => query.toLowerCase().includes(String(k).toLowerCase()))
|
|
96
|
+
const symHit = (tr.symbols || []).some((s) => query.includes(s))
|
|
97
|
+
if (!kwHit && !symHit) continue
|
|
98
|
+
if (Array.isArray(tr.scope) && tr.scope.length && tags.length) {
|
|
99
|
+
if (!tr.scope.some((s) => tags.includes(s))) continue // 画像过滤
|
|
100
|
+
}
|
|
101
|
+
matched.push({ it, score: 1 })
|
|
102
|
+
}
|
|
103
|
+
// procedure 之外的相关 insights(project + global 非草稿非归档),lexical top-k
|
|
104
|
+
const cands = [...(store ? store.insightItems() : []), ...gItems]
|
|
105
|
+
for (const it of cands) {
|
|
106
|
+
if (!it || it.archived || it.draft) continue
|
|
107
|
+
if (it.kind === 'procedure') continue // procedure 只经 trigger 通道注入,避免绕过 scope 过滤
|
|
108
|
+
const score = normalizedTokenOverlap(query, insightMatchText(it))
|
|
109
|
+
if (score >= (cfg.relevanceMin || 0.25)) matched.push({ it, score })
|
|
110
|
+
}
|
|
111
|
+
matched.sort((a, b) => b.score - a.score)
|
|
112
|
+
const seen = new Set()
|
|
113
|
+
for (const { it } of matched) {
|
|
114
|
+
if (seen.has(it.id)) continue
|
|
115
|
+
seen.add(it.id)
|
|
116
|
+
const label = it.kind === 'procedure' ? 'procedure' : 'insight'
|
|
117
|
+
const body =
|
|
118
|
+
it.kind === 'procedure' && Array.isArray(it.steps)
|
|
119
|
+
? `【${it.title}】\n${it.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
|
|
120
|
+
: `- [${it.id}] ${it.title}${it.fix || it.solution || it.choice ? ` — ${it.fix || it.solution || it.choice}` : ''}`
|
|
121
|
+
parts.push(body)
|
|
122
|
+
labels.push(label)
|
|
123
|
+
if (parts.join('\n').length > budgetChars) break
|
|
124
|
+
}
|
|
125
|
+
// 常驻块文本(内容哈希由 wrapper 判“是否已注入过”)
|
|
126
|
+
const entry = task ? buildEntryContent(task, cfg) : ''
|
|
127
|
+
const total = [entry, ...parts].filter(Boolean)
|
|
128
|
+
if (!total.length) return { text: '', labels: [] }
|
|
129
|
+
let text = total.join('\n')
|
|
130
|
+
if (text.length > budgetChars) text = text.slice(0, budgetChars) + '\n…(截断)'
|
|
131
|
+
return { text, labels }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 包装 llm.stream。resolver(ctx, config) 返回项目 root;返回 null → 完全透传。
|
|
136
|
+
* opts.state 持有“当前注入指纹”(跨请求去重)。
|
|
137
|
+
*/
|
|
138
|
+
export function wrapLlmStream(streamFn, config, { resolveRoot, state, llmName = 'llm' } = {}) {
|
|
139
|
+
const auto = (config && config.autoContext) || {}
|
|
140
|
+
if (auto.enabled === false) return streamFn
|
|
141
|
+
const orig = typeof streamFn === 'function' ? streamFn : () => {}
|
|
142
|
+
const cfg = cfgEngine(config)
|
|
143
|
+
const mem = state || {}
|
|
144
|
+
return async function* wrapped(params) {
|
|
145
|
+
try {
|
|
146
|
+
const resolved = resolveRoot ? resolveRoot(config) : null
|
|
147
|
+
const root = typeof resolved === 'string' ? resolved : (resolved && resolved.root) || null
|
|
148
|
+
const sessionId = resolved && typeof resolved === 'object' ? resolved.sessionId || null : null
|
|
149
|
+
if (root) {
|
|
150
|
+
const query = lastUserText(params && params.messages)
|
|
151
|
+
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
152
|
+
const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
|
|
153
|
+
const sid = sessionId || null
|
|
154
|
+
const boundTaskId = sid && store.getBoundTaskId(sid) ? store.getBoundTaskId(sid) : null
|
|
155
|
+
const task = boundTaskId ? store.getTask(boundTaskId) : null
|
|
156
|
+
const built = buildInjection({ query, task, store, globalStore, projectTagsList: projectTags(root), cfg })
|
|
157
|
+
const fp = fingerprint(built.text)
|
|
158
|
+
if (built.text && fp !== mem.lastFp) {
|
|
159
|
+
const last = params.messages && params.messages[params.messages.length - 1]
|
|
160
|
+
if (last && last.role === 'user') {
|
|
161
|
+
const content = Array.isArray(last.content) ? last.content : []
|
|
162
|
+
content.push({ type: 'text', text: `\n\n${INJECT_MARK} ${llmName}\n${built.text}` })
|
|
163
|
+
last.content = content
|
|
164
|
+
mem.lastFp = fp
|
|
165
|
+
if (!mem.loggedFire) {
|
|
166
|
+
mem.loggedFire = true
|
|
167
|
+
console.log(`[dsh-project-memory] auto-context: FIRST injection fired (${built.text.length} chars, root=${root}${sessionId ? `, session=${sessionId}` : ''})`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
|
|
174
|
+
}
|
|
175
|
+
// 无论注入与否都原样走宿主 stream(异常同样原样传播)
|
|
176
|
+
yield* orig(params)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。 */
|
|
181
|
+
export function installAutoInject(ctx, config) {
|
|
182
|
+
const auto = (config && config.autoContext) || {}
|
|
183
|
+
if (auto.enabled === false) return
|
|
184
|
+
const cfg = cfgEngine(config)
|
|
185
|
+
const mem = {}
|
|
186
|
+
ctx.on('agent/pre-step', async (next, { agent, session }) => {
|
|
187
|
+
const decision = await next()
|
|
188
|
+
if (!decision || decision.kind !== 'enter') return decision
|
|
189
|
+
const root = session?.header?.cwd
|
|
190
|
+
const sessionId = session?.id
|
|
191
|
+
if (!root) return decision
|
|
192
|
+
try {
|
|
193
|
+
const query = lastUserText(decision.messages)
|
|
194
|
+
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
195
|
+
const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
|
|
196
|
+
const boundTaskId = sessionId && store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
|
|
197
|
+
const task = boundTaskId ? store.getTask(boundTaskId) : null
|
|
198
|
+
const built = buildInjection({ query: query || '', task, store, globalStore, projectTagsList: projectTags(root), cfg })
|
|
199
|
+
const fp = fingerprint(built.text)
|
|
200
|
+
if (built.text && fp !== mem.lastFp) {
|
|
201
|
+
const last = decision.messages && decision.messages[decision.messages.length - 1]
|
|
202
|
+
if (last && last.role === 'user') {
|
|
203
|
+
const content = Array.isArray(last.content) ? last.content : []
|
|
204
|
+
content.push({ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` })
|
|
205
|
+
last.content = content
|
|
206
|
+
mem.lastFp = fp
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
|
|
211
|
+
}
|
|
212
|
+
return decision
|
|
213
|
+
})
|
|
214
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory View — 记忆三作用域列表(Task 草稿 / Project / Global)
|
|
3
|
+
* 数据通道与任务面板一致:remote.commands.execute 执行 /insight 命令,取 JSON 载荷。
|
|
4
|
+
* 动作:confirm(草稿审核) / promote(向上提升) / demote(向下降级,需绑定任务) / archive / restore / delete
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useCallback, useRef, useState } from 'react'
|
|
7
|
+
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
8
|
+
import css from './TaskPanel.module.css'
|
|
9
|
+
|
|
10
|
+
export type InsightScope = 'task' | 'project' | 'global'
|
|
11
|
+
|
|
12
|
+
export interface InsightRow {
|
|
13
|
+
id: string
|
|
14
|
+
kind: string
|
|
15
|
+
scope: InsightScope
|
|
16
|
+
title: string
|
|
17
|
+
body?: string
|
|
18
|
+
fix?: string
|
|
19
|
+
solution?: string
|
|
20
|
+
reason?: string
|
|
21
|
+
pattern?: string
|
|
22
|
+
problem?: string
|
|
23
|
+
choice?: string
|
|
24
|
+
draft?: boolean
|
|
25
|
+
archived?: boolean
|
|
26
|
+
confidence?: number | null
|
|
27
|
+
hitCount?: number
|
|
28
|
+
members?: number
|
|
29
|
+
files?: number
|
|
30
|
+
symbols?: number
|
|
31
|
+
trigger?: { keywords: string[]; scope: string[] } | null
|
|
32
|
+
steps?: string[]
|
|
33
|
+
taskId?: string
|
|
34
|
+
taskTitle?: string
|
|
35
|
+
updatedAt?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ListPayload {
|
|
39
|
+
scope: InsightScope
|
|
40
|
+
count: number
|
|
41
|
+
items: InsightRow[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseInsightPayloadText(text?: string | null): ListPayload | null {
|
|
45
|
+
if (!text) return null
|
|
46
|
+
let jsonBlock: string | null = null
|
|
47
|
+
const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
|
|
48
|
+
if (fenced) jsonBlock = fenced[1]
|
|
49
|
+
else {
|
|
50
|
+
const lastBrace = text.lastIndexOf('{')
|
|
51
|
+
if (lastBrace !== -1) jsonBlock = text.slice(lastBrace)
|
|
52
|
+
}
|
|
53
|
+
if (!jsonBlock) return null
|
|
54
|
+
try {
|
|
55
|
+
const data = JSON.parse(jsonBlock)
|
|
56
|
+
if (!Array.isArray(data.items)) return null
|
|
57
|
+
return { scope: data.scope ?? 'project', count: data.count ?? data.items.length, items: data.items }
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function kindText(kind: string, t: any): string {
|
|
64
|
+
const map: Record<string, string> = {
|
|
65
|
+
lesson: t('mem.kind.lesson'),
|
|
66
|
+
decision: t('mem.kind.decision'),
|
|
67
|
+
procedure: t('mem.kind.procedure'),
|
|
68
|
+
experience: t('mem.kind.experience'),
|
|
69
|
+
}
|
|
70
|
+
return map[kind] ?? kind
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function MemoryView({
|
|
74
|
+
ctx,
|
|
75
|
+
sessionId,
|
|
76
|
+
scope,
|
|
77
|
+
boundTaskId,
|
|
78
|
+
t,
|
|
79
|
+
}: {
|
|
80
|
+
ctx: any
|
|
81
|
+
sessionId: string | null
|
|
82
|
+
scope: InsightScope
|
|
83
|
+
boundTaskId: string | null
|
|
84
|
+
t: any
|
|
85
|
+
}) {
|
|
86
|
+
const [payload, setPayload] = useState<ListPayload | null>(null)
|
|
87
|
+
const [error, setError] = useState<string | null>(null)
|
|
88
|
+
const [loading, setLoading] = useState(false)
|
|
89
|
+
const [busy, setBusy] = useState(false)
|
|
90
|
+
const [showNew, setShowNew] = useState(false)
|
|
91
|
+
const [editingId, setEditingId] = useState<string | null>(null)
|
|
92
|
+
const [form, setForm] = useState({ title: '', kind: 'lesson', body: '', steps: '', trigger: '' })
|
|
93
|
+
const [savedTip, setSavedTip] = useState<string | null>(null)
|
|
94
|
+
|
|
95
|
+
const runLine = useCallback(
|
|
96
|
+
async (line: string): Promise<{ ok: boolean; text: string }> => {
|
|
97
|
+
const commands = ctx?.remote?.commands
|
|
98
|
+
if (!sessionId || !commands || typeof commands.execute !== 'function') {
|
|
99
|
+
return { ok: false, text: 'no session / commands service' }
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const response = await commands.execute(sessionId, line, [])
|
|
103
|
+
const envelope = response as { ok?: boolean; value?: any } | null | undefined
|
|
104
|
+
const execution = envelope && typeof envelope === 'object' && 'value' in envelope ? envelope.value : envelope
|
|
105
|
+
const result = execution?.result ?? execution
|
|
106
|
+
if (result?.kind === 'error') return { ok: false, text: result.text ?? 'command error' }
|
|
107
|
+
return { ok: true, text: result?.text ?? '' }
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { ok: false, text: String((err as Error)?.message ?? err) }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[ctx, sessionId],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// 用 ref 做并发/频控守卫:loading 状态不能进 refresh 的依赖数组,
|
|
116
|
+
// 否则 loading 每次翻转都会重建 refresh → effect 重跑 → 无限循环刷 /insight 命令。
|
|
117
|
+
const inflightRef = useRef(false)
|
|
118
|
+
const lastRunRef = useRef(0)
|
|
119
|
+
|
|
120
|
+
const refresh = useCallback(async (force = false): Promise<void> => {
|
|
121
|
+
if (inflightRef.current) return
|
|
122
|
+
const now = Date.now()
|
|
123
|
+
if (!force && now - lastRunRef.current < 800) return
|
|
124
|
+
inflightRef.current = true
|
|
125
|
+
lastRunRef.current = now
|
|
126
|
+
setLoading(true)
|
|
127
|
+
setError(null)
|
|
128
|
+
try {
|
|
129
|
+
const res = await runLine(`/insight list ${scope}`)
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
setError(res.text)
|
|
132
|
+
setPayload(null)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const parsed = parseInsightPayloadText(res.text)
|
|
136
|
+
setPayload(parsed)
|
|
137
|
+
if (!parsed) setError('unparsable payload')
|
|
138
|
+
} finally {
|
|
139
|
+
inflightRef.current = false
|
|
140
|
+
setLoading(false)
|
|
141
|
+
}
|
|
142
|
+
}, [runLine, scope])
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
void refresh()
|
|
146
|
+
}, [refresh])
|
|
147
|
+
|
|
148
|
+
const act = async (action: string, id: string): Promise<void> => {
|
|
149
|
+
if (busy) return
|
|
150
|
+
setBusy(true)
|
|
151
|
+
setError(null)
|
|
152
|
+
try {
|
|
153
|
+
const line = `/insight ${action} ${scope} ${id}${action === 'demote' && scope === 'project' && boundTaskId ? ` ${boundTaskId}` : ''}`
|
|
154
|
+
const res = await runLine(line)
|
|
155
|
+
if (!res.ok) setError(res.text)
|
|
156
|
+
else void refresh(true)
|
|
157
|
+
} finally {
|
|
158
|
+
setBusy(false)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rowToForm = (row: InsightRow) => {
|
|
163
|
+
// 正文按 kind 取专属字段(fix/solution/reason),避免把与标题相同的 pattern/problem 当正文
|
|
164
|
+
const main =
|
|
165
|
+
row.kind === 'lesson' ? row.fix
|
|
166
|
+
: row.kind === 'experience' ? row.solution
|
|
167
|
+
: row.kind === 'decision' ? row.reason
|
|
168
|
+
: row.body
|
|
169
|
+
const body = main && main !== row.title ? main : ''
|
|
170
|
+
return {
|
|
171
|
+
title: row.title || '',
|
|
172
|
+
kind: row.kind || 'lesson',
|
|
173
|
+
body,
|
|
174
|
+
steps: Array.isArray((row as InsightRow & { steps?: string[] }).steps) ? ((row as InsightRow & { steps?: string[] }).steps as string[]).join('\n') : '',
|
|
175
|
+
trigger: (row.trigger?.keywords || []).join(','),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const startEdit = (row: InsightRow) => {
|
|
180
|
+
setEditingId(row.id)
|
|
181
|
+
setForm(rowToForm(row))
|
|
182
|
+
setShowNew(true)
|
|
183
|
+
setError(null)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const closeForm = () => {
|
|
187
|
+
setShowNew(false)
|
|
188
|
+
setEditingId(null)
|
|
189
|
+
setForm({ title: '', kind: 'lesson', body: '', steps: '', trigger: '' })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 切换类型时把当前输入迁移到目标类型的主内容区,避免换类型后“刚写的东西消失”
|
|
193
|
+
const changeKind = (next: string) => {
|
|
194
|
+
setForm(prev => {
|
|
195
|
+
if (prev.kind === next) return prev
|
|
196
|
+
const fromProc = prev.kind === 'procedure'
|
|
197
|
+
const toProc = next === 'procedure'
|
|
198
|
+
let body = prev.body
|
|
199
|
+
let steps = prev.steps
|
|
200
|
+
if (fromProc && !toProc && !body && steps.trim()) body = steps.trim() // procedure → 正文
|
|
201
|
+
if (toProc && !fromProc && !steps.trim() && body.trim()) steps = body.trim() // 正文 → 步骤
|
|
202
|
+
return { ...prev, kind: next, body, steps }
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const submitNew = async (): Promise<void> => {
|
|
207
|
+
if (scope === 'task' && !editingId) return
|
|
208
|
+
if (busy) return
|
|
209
|
+
const title = form.title.trim()
|
|
210
|
+
if (!title) return
|
|
211
|
+
setBusy(true)
|
|
212
|
+
setError(null)
|
|
213
|
+
try {
|
|
214
|
+
const fields: Record<string, unknown> = { title, kind: form.kind }
|
|
215
|
+
if (form.kind === 'lesson') fields.pattern = title
|
|
216
|
+
if (form.kind === 'decision') fields.choice = title
|
|
217
|
+
if (form.kind === 'experience') fields.problem = title
|
|
218
|
+
if (form.body.trim()) {
|
|
219
|
+
if (form.kind === 'lesson') fields.fix = form.body.trim()
|
|
220
|
+
else if (form.kind === 'decision') fields.reason = form.body.trim()
|
|
221
|
+
else if (form.kind === 'experience') fields.solution = form.body.trim()
|
|
222
|
+
else fields.body = form.body.trim()
|
|
223
|
+
}
|
|
224
|
+
if (form.kind === 'procedure' && form.steps.trim()) {
|
|
225
|
+
fields.steps = form.steps.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
226
|
+
}
|
|
227
|
+
if (form.kind === 'procedure' && form.trigger.trim()) {
|
|
228
|
+
fields.trigger = { keywords: form.trigger.split(/[,,]/).map((s) => s.trim()).filter(Boolean) }
|
|
229
|
+
}
|
|
230
|
+
const line = editingId
|
|
231
|
+
? `/insight edit ${scope} ${editingId} ${JSON.stringify(fields)}`
|
|
232
|
+
: `/insight save ${scope} ${JSON.stringify(fields)}`
|
|
233
|
+
const res = await runLine(line)
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
setError(res.text)
|
|
236
|
+
} else {
|
|
237
|
+
setSavedTip(t('mem.saved'))
|
|
238
|
+
closeForm()
|
|
239
|
+
window.setTimeout(() => setSavedTip(null), 2500)
|
|
240
|
+
void refresh(true)
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
setBusy(false)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const items = payload?.items ?? []
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div className={css.memoryPane}>
|
|
251
|
+
<div className={css.memToolbar}>
|
|
252
|
+
<span className={css.memCount}>
|
|
253
|
+
{scope === 'task' ? t('mem.scope.task') : scope === 'global' ? t('mem.scope.global') : t('mem.scope.project')}
|
|
254
|
+
{payload ? ` · ${payload.count}` : ''}
|
|
255
|
+
</span>
|
|
256
|
+
<span className={css.memToolbarActs}>
|
|
257
|
+
{savedTip && <span className={css.badge}>{savedTip}</span>}
|
|
258
|
+
{scope !== 'task' && !showNew && (
|
|
259
|
+
<Button variant="outline" size="sm" disabled={busy} onClick={() => { setShowNew(true); setEditingId(null); setError(null) }}>{t('mem.new')}</Button>
|
|
260
|
+
)}
|
|
261
|
+
<Button variant="outline" size="sm" onClick={() => void refresh(true)} disabled={loading} title={t('panel.refresh')} aria-label={t('panel.refresh')}>
|
|
262
|
+
↻
|
|
263
|
+
</Button>
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
{showNew && scope !== 'task' && (
|
|
267
|
+
<div className={css.memNew}>
|
|
268
|
+
<input className={css.stepInput} placeholder={t('mem.f.title')} value={form.title}
|
|
269
|
+
onChange={(e) => setForm({ ...form, title: e.target.value })} autoFocus />
|
|
270
|
+
<div className={css.memNewRow}>
|
|
271
|
+
<label className={css.memLabel}>{t('mem.f.kind')}
|
|
272
|
+
<select className={css.stepInput} value={form.kind} onChange={(e) => changeKind(e.target.value)}>
|
|
273
|
+
<option value="lesson">lesson</option>
|
|
274
|
+
<option value="decision">decision</option>
|
|
275
|
+
<option value="procedure">procedure</option>
|
|
276
|
+
<option value="experience">experience</option>
|
|
277
|
+
</select>
|
|
278
|
+
</label>
|
|
279
|
+
</div>
|
|
280
|
+
{form.kind !== 'procedure' && (
|
|
281
|
+
<textarea className={css.stepInput} placeholder={t('mem.f.body')} rows={2} value={form.body}
|
|
282
|
+
onChange={(e) => setForm({ ...form, body: e.target.value })} />
|
|
283
|
+
)}
|
|
284
|
+
{form.kind === 'procedure' && (
|
|
285
|
+
<>
|
|
286
|
+
<textarea className={css.stepInput} placeholder={t('mem.f.steps')} rows={3} value={form.steps}
|
|
287
|
+
onChange={(e) => setForm({ ...form, steps: e.target.value })} />
|
|
288
|
+
<input className={css.stepInput} placeholder={t('mem.f.trigger')} value={form.trigger}
|
|
289
|
+
onChange={(e) => setForm({ ...form, trigger: e.target.value })} />
|
|
290
|
+
</>
|
|
291
|
+
)}
|
|
292
|
+
<div className={css.memActs}>
|
|
293
|
+
<Button variant="outline" size="sm" disabled={busy || !form.title.trim()} onClick={() => void submitNew()}>{t('mem.save')}</Button>
|
|
294
|
+
<Button variant="outline" size="sm" onClick={closeForm}>{t('mem.cancel')}</Button>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
{error && <div className={css.notice}>{t('mem.error')}: {error}</div>}
|
|
299
|
+
{loading && !items.length && <div className={css.muted}>{t('mem.loading')}</div>}
|
|
300
|
+
{!loading && !error && items.length === 0 && <div className={css.muted}>{t('mem.empty')}</div>}
|
|
301
|
+
<div className={css.memList}>
|
|
302
|
+
{items.map((row) => {
|
|
303
|
+
const canDemoteToTask = scope === 'project' && !!boundTaskId
|
|
304
|
+
return (
|
|
305
|
+
<div key={row.id} className={`${css.memRow}${row.archived ? ` ${css.memRowArchived}` : ''}`}>
|
|
306
|
+
<div className={css.memRowMain}>
|
|
307
|
+
<span className={css.memTitle}>
|
|
308
|
+
<span className={css.kindBadge}>{kindText(row.kind, t)}</span>
|
|
309
|
+
{row.title || row.body}
|
|
310
|
+
</span>
|
|
311
|
+
<span className={css.memMeta}>
|
|
312
|
+
{row.draft && <span className={`${css.badge} ${css.badgeDraft}`}>{t('mem.draft')}</span>}
|
|
313
|
+
{row.archived && <span className={`${css.badge} ${css.badgeArchived}`}>{t('mem.archived')}</span>}
|
|
314
|
+
{row.taskTitle && <span className={css.badge}>@{row.taskTitle}</span>}
|
|
315
|
+
{row.members !== undefined && row.members > 0 && <span className={css.badge}>×{row.members}</span>}
|
|
316
|
+
{row.confidence != null && <span className={css.badge}>{Math.round(row.confidence * 100)}%</span>}
|
|
317
|
+
{row.trigger && row.trigger.keywords.length > 0 && <span className={css.badge}>⚡{row.trigger.keywords.slice(0, 2).join(',')}</span>}
|
|
318
|
+
</span>
|
|
319
|
+
</div>
|
|
320
|
+
{row.body && row.body !== row.title && <div className={css.memBody}>{String(row.body).slice(0, 160)}</div>}
|
|
321
|
+
<div className={css.memActs}>
|
|
322
|
+
<Button variant="outline" size="sm" disabled={busy} onClick={() => startEdit(row)}>{t('mem.edit')}</Button>
|
|
323
|
+
{scope === 'task' && !row.archived && (
|
|
324
|
+
<>
|
|
325
|
+
{row.draft && <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('confirm', row.id)}>{t('mem.confirm')}</Button>}
|
|
326
|
+
<Button variant="outline" size="sm" disabled={busy} onClick={() => void act('promote', row.id)}>{t('mem.promote')}</Button>
|
|
327
|
+
</>
|
|
328
|
+
)}
|
|
329
|
+
{scope === 'project' && (
|
|
330
|
+
<>
|
|
331
|
+
<Button variant="outline" size="sm" disabled={busy} onClick={() => void act('promote', row.id)}>{t('mem.promote')}</Button>
|
|
332
|
+
<Button variant="outline" size="sm" disabled={busy || !canDemoteToTask} title={!canDemoteToTask ? t('mem.needs-bound') : undefined} onClick={() => void act('demote', row.id)}>{t('mem.demote')}</Button>
|
|
333
|
+
</>
|
|
334
|
+
)}
|
|
335
|
+
{scope === 'global' && (
|
|
336
|
+
<Button variant="outline" size="sm" disabled={busy} onClick={() => void act('demote', row.id)}>{t('mem.demote')}</Button>
|
|
337
|
+
)}
|
|
338
|
+
{row.archived
|
|
339
|
+
? <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('restore', row.id)}>{t('mem.restore')}</Button>
|
|
340
|
+
: <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('archive', row.id)}>{t('mem.archive')}</Button>}
|
|
341
|
+
<Button variant="outline" size="sm" disabled={busy} onClick={() => void act('delete', row.id)}>{t('mem.delete')}</Button>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
)
|
|
345
|
+
})}
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /tasks、/task 命令在会话中的节点渲染器(conversation.chat.commandview,按命令名 key)。
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* - 列表型(/tasks、无动词的 /task):只渲染一行极简“已同步 N 套”状态;
|
|
6
|
-
* 同时把节点文本里的 JSON 载荷解析进浮动面板(侧边卡片由它驱动),
|
|
7
|
-
* 面板若被收起则自动展开(等价“输入 /task 就看到卡片”)。
|
|
8
|
-
* - 动作型(/task switch|archive):渲染一行操作结果(切换/归档反馈)。
|
|
9
|
-
* - 错误/执行中:一行极简状态,不倾倒原文。
|
|
2
|
+
* /tasks、/task、/insight 命令在会话中的节点渲染器(conversation.chat.commandview,按命令名 key)。
|
|
3
|
+
* 列表型命令(/tasks、无动词 /task、/insight list)只显示一行摘要;/insight 的动作类只显示一句结果——
|
|
4
|
+
* 目的:大 JSON 载荷绝不整段渲染进对话(否则切视图会刷屏)。
|
|
10
5
|
*/
|
|
11
|
-
import { useEffect } from 'react'
|
|
6
|
+
import { useEffect, useRef } from 'react'
|
|
12
7
|
import { IconFolderOpenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
13
|
-
import { parseTaskPayloadText,
|
|
8
|
+
import { parseTaskPayloadText, taskDataStore } from './task-data-store.ts'
|
|
9
|
+
import { parseInsightPayloadText } from './MemoryView.tsx'
|
|
10
|
+
import { taskUIStore } from './task-ui-store.ts'
|
|
14
11
|
import css from './TaskCommandNode.module.css'
|
|
15
12
|
|
|
16
|
-
/** node = CommandRowOwnerProps.node(CommandNode: name / args / outcome{kind,text}) */
|
|
17
13
|
export function TaskCommandNode({ node }: { node?: any }) {
|
|
18
14
|
const name = node?.name ?? 'task'
|
|
19
15
|
const outcome = node?.outcome ?? null
|
|
@@ -22,34 +18,50 @@ export function TaskCommandNode({ node }: { node?: any }) {
|
|
|
22
18
|
const isList = name === 'tasks' || !verb
|
|
23
19
|
const text = outcome?.text ?? ''
|
|
24
20
|
|
|
25
|
-
const
|
|
21
|
+
const parsedTasks = name !== 'insight' ? parseTaskPayloadText(text) : null
|
|
22
|
+
const parsedInsights = name === 'insight' ? parseInsightPayloadText(text) : null
|
|
26
23
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
24
|
+
// 该节点是否“现场执行”(挂载时处于执行中):为 true 说明是用户当场输入的命令,
|
|
25
|
+
// 完成后同步数据并打开面板。刷新/切换会话后历史节点以完成态重挂载,
|
|
26
|
+
// 此时只做展示、不再写数据/开面板(面板保持关闭,与“刷新后不显示”一致)。
|
|
27
|
+
const live = useRef(outcome === null)
|
|
28
|
+
|
|
29
|
+
// 载荷进数据 store;列表型任务命令(/tasks、无动词 /task)同步数据并打开面板
|
|
30
30
|
useEffect(() => {
|
|
31
|
-
if (!
|
|
32
|
-
|
|
33
|
-
tasks:
|
|
34
|
-
boundTaskId:
|
|
35
|
-
archivedCount:
|
|
31
|
+
if (!parsedTasks || !live.current) return
|
|
32
|
+
taskDataStore.actions.setTasks({
|
|
33
|
+
tasks: parsedTasks.tasks,
|
|
34
|
+
boundTaskId: parsedTasks.boundId,
|
|
35
|
+
archivedCount: parsedTasks.archived,
|
|
36
36
|
})
|
|
37
|
-
if (isList)
|
|
38
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
if (isList) taskUIStore.actions.open()
|
|
39
38
|
}, [text])
|
|
40
39
|
|
|
41
|
-
// 执行中
|
|
42
40
|
if (outcome === null) {
|
|
43
41
|
return <div className={css.row} data-variant="running">/{name} 执行中…</div>
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
// 失败:一行极简,不含大段原文
|
|
47
44
|
if (outcome.kind === 'error') {
|
|
48
45
|
const brief = String(text ?? '').split('\n')[0].slice(0, 120)
|
|
49
46
|
return <div className={css.row} data-variant="error">/{name} 失败{brief ? `:${brief}` : ''}</div>
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
//
|
|
49
|
+
// /insight:列表只显示一行摘要,动作类只显示一句结果(永不整段渲染)
|
|
50
|
+
if (name === 'insight') {
|
|
51
|
+
if (verb === 'list' && parsedInsights) {
|
|
52
|
+
const scope = parsedInsights.scope
|
|
53
|
+
const count = parsedInsights.count ?? parsedInsights.items.length
|
|
54
|
+
return (
|
|
55
|
+
<div className={css.row} data-variant="ok">
|
|
56
|
+
<IconFolderOpenOutline16 className={css.icon} />
|
|
57
|
+
<span>记忆 {scope} · {count} 条 · 已同步到记忆视图</span>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
const note = String(text ?? '').split('\n')[0].slice(0, 120)
|
|
62
|
+
return <div className={css.row} data-variant="ok">/insight {verb} {note || '已完成'}</div>
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
if (!isList) {
|
|
54
66
|
const note = String(text ?? '')
|
|
55
67
|
.split(/\n{2,}/)[0]
|
|
@@ -59,10 +71,9 @@ export function TaskCommandNode({ node }: { node?: any }) {
|
|
|
59
71
|
return <div className={css.row} data-variant="ok">/{name} {note || '已完成'}</div>
|
|
60
72
|
}
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const label = parsed
|
|
74
|
+
const count = parsedTasks ? parsedTasks.tasks.length : 0
|
|
75
|
+
const archived = parsedTasks ? parsedTasks.archived : 0
|
|
76
|
+
const label = parsedTasks
|
|
66
77
|
? `任务 ${count} 套${archived ? `(归档 ${archived})` : ''} · 已同步到任务面板`
|
|
67
78
|
: `/${name} 已执行`
|
|
68
79
|
return (
|
|
@@ -71,4 +82,4 @@ export function TaskCommandNode({ node }: { node?: any }) {
|
|
|
71
82
|
<span>{label}</span>
|
|
72
83
|
</div>
|
|
73
84
|
)
|
|
74
|
-
}
|
|
85
|
+
}
|