@zoria-lind/dsh-ecolink-adapter 0.2.2 → 0.3.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/README.md +45 -86
- package/adapter/README.md +45 -86
- package/adapter/cordis.patch.yml +2 -2
- package/adapter/src/client.js +117 -0
- package/adapter/src/config.js +5 -5
- package/adapter/src/index.js +9 -28
- package/adapter/src/modules/memoryAccess.js +27 -0
- package/adapter/src/modules/memoryDiff.js +31 -116
- package/adapter/src/modules/memoryInject.js +80 -189
- package/adapter/src/modules/memoryPanel.js +156 -0
- package/adapter/src/modules/memoryReader.js +87 -0
- package/adapter/src/modules/memoryWrite.js +3 -2
- package/adapter/src/modules/serviceGuard.js +22 -12
- package/extension/core/artifact.mjs +208 -0
- package/extension/core/entry.mjs +7 -0
- package/extension/core/queue.mjs +10 -9
- package/extension/core/selector.mjs +89 -88
- package/package.json +11 -8
- package/service/README.md +2 -0
- package/service/package.json +1 -1
- package/service/pool.mjs +58 -8
- package/service/server.mjs +9 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { entryIdentity, injectionPolicy } from '../../../extension/core/entry.mjs'
|
|
6
|
+
export const digest = value => createHash('sha256').update(String(value)).digest('hex')
|
|
7
|
+
export const versionOf = item => digest(entryIdentity(item))
|
|
8
|
+
export const expandHome = p => /^~(?:[/\\]|$)/.test(String(p)) ? join(homedir(), String(p).slice(2)) : p
|
|
9
|
+
function recordTime(item) {
|
|
10
|
+
const time = Date.parse(item.timestamp)
|
|
11
|
+
return Number.isFinite(time) ? time : -Infinity
|
|
12
|
+
}
|
|
13
|
+
export function flattenPool(pool) {
|
|
14
|
+
const result = []
|
|
15
|
+
const add = (items, poolId, poolName) => {
|
|
16
|
+
for (const item of items ?? []) {
|
|
17
|
+
if (!item || typeof item.content !== 'string') continue
|
|
18
|
+
const id = item.id ?? `legacy:${digest(entryIdentity(item))}`
|
|
19
|
+
result.push({ ...item, id, poolId, poolName, ref: JSON.stringify([poolId, id]),
|
|
20
|
+
version: versionOf(item), injectionPolicy: injectionPolicy(item) })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
add(pool?.shared_pool, 'shared', '共享池')
|
|
24
|
+
for (const [id, sp] of Object.entries(pool?.session_pools ?? {})) add(sp.memories, id, sp.identity ?? id)
|
|
25
|
+
return result.sort((a,b) => (recordTime(b) - recordTime(a)) || a.ref.localeCompare(b.ref))
|
|
26
|
+
}
|
|
27
|
+
export function catalogEntry(e) {
|
|
28
|
+
const content = Array.from(e.content)
|
|
29
|
+
return { id:e.id, ref:e.ref, key:e.key ?? null, pool:e.poolName, poolId:e.poolId,
|
|
30
|
+
version:e.version, injectionPolicy:e.injectionPolicy, kind:e.kind ?? 'fact',
|
|
31
|
+
timestamp:e.timestamp ?? null, chars:content.length, preview:content.slice(0,80).join(''), partial:content.length>80 }
|
|
32
|
+
}
|
|
33
|
+
function integer(value, fallback, max) {
|
|
34
|
+
if (value === undefined) return fallback
|
|
35
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > max) throw new Error('分页参数越界')
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
export function createMemoryReader(config, deps={}) {
|
|
39
|
+
const readFile=deps.readFile ?? (p=>readFileSync(p,'utf8'))
|
|
40
|
+
const entries=()=>flattenPool(JSON.parse(readFile(expandHome(config.poolPath))))
|
|
41
|
+
function select(args={}) {
|
|
42
|
+
let all=entries()
|
|
43
|
+
if (args.pool) {
|
|
44
|
+
const ids=[...new Set(all.filter(e=>e.poolId===args.pool || e.poolName===args.pool).map(e=>e.poolId))]
|
|
45
|
+
if (ids.length>1) throw new Error(`池名有歧义,请用 poolId:${JSON.stringify(ids)}`)
|
|
46
|
+
all=all.filter(e=>e.poolId===ids[0])
|
|
47
|
+
}
|
|
48
|
+
if (args.ref) all=all.filter(e=>e.ref===args.ref)
|
|
49
|
+
else if (args.id) all=all.filter(e=>e.id===args.id)
|
|
50
|
+
else if (args.key) all=all.filter(e=>e.key===args.key)
|
|
51
|
+
if (args.query) all=all.filter(e=>[e.key,e.poolName,e.content].some(s=>String(s??'').includes(args.query)))
|
|
52
|
+
if (args.injectionPolicy !== undefined) {
|
|
53
|
+
if (!['manual','universal'].includes(args.injectionPolicy)) throw new Error('记忆类型无效')
|
|
54
|
+
all=all.filter(e=>e.injectionPolicy===args.injectionPolicy)
|
|
55
|
+
}
|
|
56
|
+
return all
|
|
57
|
+
}
|
|
58
|
+
function list(args={}) {
|
|
59
|
+
const all=select(args), revision=digest(all.map(e=>e.ref+e.version).join('\n'))
|
|
60
|
+
if (args.revision && args.revision!==revision) throw new Error('目录已变化,请从第一页重新读取')
|
|
61
|
+
const offset=integer(args.offset,0,all.length), limit=integer(args.limit,10,20)
|
|
62
|
+
if (!limit) throw new Error('limit 必须大于0')
|
|
63
|
+
const items=all.slice(offset,offset+limit).map(catalogEntry), next=offset+items.length
|
|
64
|
+
return { kind:'catalog', revision, total:all.length, offset, items, nextOffset:next<all.length?next:null,
|
|
65
|
+
note:'目录仅含摘要;用 ecolink_memory action=read 和 ref/version 分页读取存储全文。' }
|
|
66
|
+
}
|
|
67
|
+
function read(args={}) {
|
|
68
|
+
if (!args.ref && !args.id && !args.key) throw new Error('请指定 ref、id 或完整 key;池名仅用于分组')
|
|
69
|
+
const all=select(args)
|
|
70
|
+
if (!all.length) return {kind:'missing',error:'没有匹配条目'}
|
|
71
|
+
if (all.length!==1) return {kind:'ambiguous',error:'名称不唯一,请用 ref 或 pool + id',items:all.slice(0,20).map(catalogEntry),total:all.length}
|
|
72
|
+
const e=all[0]
|
|
73
|
+
if (args.version && args.version!==e.version) throw new Error('条目版本已变化,请重新预览')
|
|
74
|
+
const chars=Array.from(e.content), offset=integer(args.offset,0,chars.length), limit=integer(args.limit,2000,4000)
|
|
75
|
+
if (!limit) throw new Error('limit 必须大于0')
|
|
76
|
+
const end=Math.min(chars.length,offset+limit)
|
|
77
|
+
return {kind:'content',...catalogEntry(e),content:chars.slice(offset,end).join(''),offset,totalChars:chars.length,
|
|
78
|
+
nextOffset:end<chars.length?end:null,partial:offset>0||end<chars.length}
|
|
79
|
+
}
|
|
80
|
+
return {entries,select,list,read}
|
|
81
|
+
}
|
|
82
|
+
export function parseReadArgs(raw, action) {
|
|
83
|
+
const input=String(raw??'').trim()
|
|
84
|
+
if (!input) return {}
|
|
85
|
+
if (input.startsWith('{')) return JSON.parse(input)
|
|
86
|
+
return action==='read'?{key:input}:{query:input}
|
|
87
|
+
}
|
|
@@ -21,11 +21,11 @@ const SERVICE_CONFIG = join(dirname(fileURLToPath(import.meta.url)), '../../../s
|
|
|
21
21
|
function npmServiceConfig() {
|
|
22
22
|
try {
|
|
23
23
|
const req = createRequire(import.meta.url)
|
|
24
|
-
return join(dirname(req.resolve('@zoria-lind/dsh-ecolink-service
|
|
24
|
+
return join(dirname(req.resolve('@zoria-lind/dsh-ecolink-service')), 'config.json')
|
|
25
25
|
} catch { return null }
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function resolveServiceToken(config) {
|
|
28
|
+
export function resolveServiceToken(config) {
|
|
29
29
|
if (config.serviceToken) return config.serviceToken
|
|
30
30
|
for (const cand of [SERVICE_CONFIG, npmServiceConfig()]) {
|
|
31
31
|
if (!cand) continue
|
|
@@ -53,6 +53,7 @@ export function createMemoryWriteModule(ctx, config, stats, deps = {}) {
|
|
|
53
53
|
if (token) headers['X-Ecolink-Token'] = token
|
|
54
54
|
const resp = await fetchImpl(`${base}/memory/sync`, {
|
|
55
55
|
method: 'POST',
|
|
56
|
+
signal: AbortSignal.timeout(8000),
|
|
56
57
|
headers,
|
|
57
58
|
body: JSON.stringify({ memories: contents.map((content) => ({ content, importance: 'called', source: 'dsh' })) }),
|
|
58
59
|
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 痛点:service 是网页扩展与 DSH 共享记忆池的唯一写者,但它是独立 node 进程,
|
|
3
3
|
// 用户必须手动 `node service/server.mjs`。本模块让 DSH 启动时自动探测并拉起:
|
|
4
4
|
// - apply 时 + 每次 agent/pre-step(60s 节流)探测 `${serviceUrl}/memory/status`;
|
|
5
|
-
// -
|
|
5
|
+
// - 200 为可达;其他 HTTP 仅视为端口占用;只在明确 ECONNREFUSED 时尝试拉起,超时不拉起。
|
|
6
6
|
// - 仅对 127.0.0.1 / localhost / ::1 生效(远程 URL 绝不 spawn);
|
|
7
7
|
// - serviceAutoStart:false 或 server.mjs 不存在(独立发布包无 service 目录)时静默跳过;
|
|
8
8
|
// - 与登录自启任务(scripts/install-autostart.ps1)兼容:端口已监听 → 探测即活,不会双起。
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// 测试:deps 注入 fetchImpl/spawnImpl/serverPath/now,见 test/smoke.mjs「服务自动拉起」节。
|
|
11
11
|
|
|
12
12
|
import { spawn } from 'node:child_process'
|
|
13
|
+
import { resolveServiceToken } from './memoryWrite.js'
|
|
13
14
|
import { existsSync } from 'node:fs'
|
|
14
15
|
import { dirname, join } from 'node:path'
|
|
15
16
|
import { fileURLToPath } from 'node:url'
|
|
@@ -22,7 +23,7 @@ const HERE = dirname(fileURLToPath(import.meta.url))
|
|
|
22
23
|
const NPM_SERVICE_PATH = (() => {
|
|
23
24
|
try {
|
|
24
25
|
const req = createRequire(import.meta.url)
|
|
25
|
-
return join(dirname(req.resolve('@zoria-lind/dsh-ecolink-service
|
|
26
|
+
return join(dirname(req.resolve('@zoria-lind/dsh-ecolink-service')), 'server.mjs')
|
|
26
27
|
} catch { return null }
|
|
27
28
|
})()
|
|
28
29
|
export const SERVICE_SERVER_CANDIDATES = [
|
|
@@ -33,7 +34,7 @@ export const SERVICE_SERVER_CANDIDATES = [
|
|
|
33
34
|
|
|
34
35
|
export function isLocalServiceUrl(url) {
|
|
35
36
|
try {
|
|
36
|
-
return ['127.0.0.1', 'localhost', '::1'].includes(new URL(String(url)).hostname)
|
|
37
|
+
return ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(new URL(String(url)).hostname)
|
|
37
38
|
} catch { return false }
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -56,19 +57,24 @@ export function createServiceGuardModule(ctx, config, stats, deps = {}) {
|
|
|
56
57
|
let inFlight = null
|
|
57
58
|
let lastCheck = 0
|
|
58
59
|
let warnedMissing = false
|
|
60
|
+
let disposed = false
|
|
61
|
+
let spawnError = null
|
|
59
62
|
|
|
60
63
|
async function isAlive() {
|
|
61
64
|
try {
|
|
62
|
-
await fetchImpl(`${base}/memory/status`, { signal: AbortSignal.timeout(
|
|
63
|
-
return
|
|
64
|
-
} catch { return
|
|
65
|
+
const resp = await fetchImpl(`${base}/memory/status`, { signal: AbortSignal.timeout(500), headers: { 'X-Ecolink-Token': resolveServiceToken(config) } })
|
|
66
|
+
return resp.status === 200 ? 'alive' : 'occupied'
|
|
67
|
+
} catch (e) { return (e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED' || e?.message === 'ECONNREFUSED') ? 'absent' : 'uncertain' }
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
async function ensureService(reason) {
|
|
71
|
+
if (disposed) return 'disposed'
|
|
68
72
|
if (inFlight) return inFlight
|
|
69
73
|
inFlight = (async () => {
|
|
70
74
|
try {
|
|
71
|
-
|
|
75
|
+
const probe = await isAlive()
|
|
76
|
+
if (probe === 'alive') return 'already-running'
|
|
77
|
+
if (probe !== 'absent') return probe
|
|
72
78
|
if (!existsSync(serverPath)) {
|
|
73
79
|
if (!warnedMissing) {
|
|
74
80
|
warnedMissing = true
|
|
@@ -76,17 +82,20 @@ export function createServiceGuardModule(ctx, config, stats, deps = {}) {
|
|
|
76
82
|
}
|
|
77
83
|
return 'skipped'
|
|
78
84
|
}
|
|
85
|
+
spawnError = null
|
|
79
86
|
const child = spawnImpl(process.execPath, [serverPath], {
|
|
80
87
|
detached: true,
|
|
81
88
|
stdio: 'ignore',
|
|
82
89
|
windowsHide: true,
|
|
83
90
|
})
|
|
91
|
+
child?.once?.('error', err => { spawnError = err })
|
|
84
92
|
child?.unref?.()
|
|
85
93
|
stats?.bump?.('serviceGuard.started')
|
|
86
94
|
// 有界就绪等待(不阻塞 DSH;未就绪也不重试,由网页端离线队列兜底)
|
|
87
95
|
const deadline = Date.now() + READY_POLL_MS
|
|
88
|
-
while (Date.now() < deadline) {
|
|
89
|
-
if (
|
|
96
|
+
while (!disposed && Date.now() < deadline) {
|
|
97
|
+
if (spawnError) throw spawnError
|
|
98
|
+
if (await isAlive() === 'alive') {
|
|
90
99
|
console.log(`[dsh-ecolink-adapter] ecolink-service 未运行,已自动启动 (pid ${child?.pid ?? '?'}) [${reason}]`)
|
|
91
100
|
return 'started'
|
|
92
101
|
}
|
|
@@ -111,16 +120,17 @@ export function createServiceGuardModule(ctx, config, stats, deps = {}) {
|
|
|
111
120
|
// ⚠ agent/pre-step 是 cordis waterfall:不调 next() 会截断整条链,决策变 undefined,
|
|
112
121
|
// 内核读 decision.kind 直接 TypeError(2026-09-14 全天 DSH 崩溃根因)。
|
|
113
122
|
// 本监听器必须原样转发 next() 结果,探测本身 fire-and-forget。
|
|
114
|
-
|
|
123
|
+
const handler = (_payload, next) => {
|
|
115
124
|
const now = nowFn()
|
|
116
125
|
if (now - lastCheck < THROTTLE_MS) return next()
|
|
117
126
|
lastCheck = now
|
|
118
127
|
ensureService('pre-step').catch(() => {})
|
|
119
128
|
return next()
|
|
120
|
-
}
|
|
129
|
+
}
|
|
130
|
+
ctx.on('agent/pre-step', handler)
|
|
121
131
|
|
|
122
132
|
// 返回 disposer 供 apply 统一清理;测试经 disposer.ensureService 显式等待
|
|
123
|
-
const dispose = () => {}
|
|
133
|
+
const dispose = () => { disposed = true; ctx.off?.('agent/pre-step', handler) }
|
|
124
134
|
dispose.ensureService = ensureService
|
|
125
135
|
return dispose
|
|
126
136
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Only return a complete, recognised answer. Rendered text is used for identity, never as saved content.
|
|
2
|
+
export function decodeArtifactStream(raw) {
|
|
3
|
+
const failure=()=>({ok:false,error:'尚未取得完整回答原文'})
|
|
4
|
+
let text='',path='',operation='APPEND',finished=false,id=null,sessionId=null,mode=null
|
|
5
|
+
let fragments=[]
|
|
6
|
+
const validFragment=f=>f&&['RESPONSE','THINK'].includes(f.type)&&typeof f.content==='string'
|
|
7
|
+
function response(value) {
|
|
8
|
+
if(!value||typeof value!=='object'||Array.isArray(value))throw Error('unknown response')
|
|
9
|
+
id=value.message_id??id;sessionId=value.chat_session_id??sessionId
|
|
10
|
+
if(Array.isArray(value.fragments)){
|
|
11
|
+
if(!value.fragments.every(validFragment))throw Error('unknown fragments')
|
|
12
|
+
fragments=value.fragments.map(f=>({...f}));finished=false
|
|
13
|
+
}else if(typeof value.content==='string'){fragments=[{type:'RESPONSE',content:value.content}];finished=false}
|
|
14
|
+
if(value.status==='FINISHED')finished=true
|
|
15
|
+
else if(value.status&&value.status!=='WIP')throw Error('incomplete response')
|
|
16
|
+
}
|
|
17
|
+
function patch(p,o,v,depth=0) {
|
|
18
|
+
if(depth>8)throw Error('nested patch')
|
|
19
|
+
if(o==='BATCH'){
|
|
20
|
+
if(!Array.isArray(v))throw Error('unknown batch')
|
|
21
|
+
for(const child of v){
|
|
22
|
+
if(!child||typeof child.p!=='string')throw Error('unknown child')
|
|
23
|
+
patch(p?p+'/'+child.p:child.p,child.o??'APPEND',child.v,depth+1)
|
|
24
|
+
}
|
|
25
|
+
}else if(p==='response'){mode='deepseek';response(v)}
|
|
26
|
+
else if(p==='response/fragments'){
|
|
27
|
+
if(!Array.isArray(v)||!v.every(validFragment))throw Error('unknown fragments')
|
|
28
|
+
mode='deepseek';finished=false
|
|
29
|
+
if(o==='APPEND')fragments.push(...v.map(f=>({...f})))
|
|
30
|
+
else if(o==='SET')fragments=v.map(f=>({...f}))
|
|
31
|
+
else throw Error('unknown operation')
|
|
32
|
+
}else if(/^response\/fragments\/-?\d+\/content$/.test(p)){
|
|
33
|
+
const n=Number(p.split('/')[2]),index=n<0?fragments.length+n:n,fragment=fragments[index]
|
|
34
|
+
if(!validFragment(fragment)||typeof v!=='string')throw Error('unknown content')
|
|
35
|
+
finished=false
|
|
36
|
+
if(o==='APPEND')fragment.content+=v
|
|
37
|
+
else if(o==='SET')fragment.content=v
|
|
38
|
+
else throw Error('unknown operation')
|
|
39
|
+
}else if(p==='response/status'){
|
|
40
|
+
if(v==='FINISHED')finished=true
|
|
41
|
+
else if(v==='WIP')finished=false
|
|
42
|
+
else throw Error('incomplete response')
|
|
43
|
+
}else if(/^response\/fragments\/-?\d+\/(elapsed_secs|references|stage_id|id|status)$/.test(p)){
|
|
44
|
+
// Timing and reference metadata do not alter the typed answer content.
|
|
45
|
+
}else if(p.includes('content')||p.includes('fragment'))throw Error('unknown content path')
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
for(const block of String(raw).split(/\r?\n\r?\n/)){
|
|
49
|
+
// SSE permits multiple data lines in one event.
|
|
50
|
+
const data=block.split(/\r?\n/).filter(l=>l.startsWith('data:')).map(l=>l.slice(5).trimStart()).join('\n').trim()
|
|
51
|
+
if(!data)continue
|
|
52
|
+
if(data==='[DONE]'){finished=true;continue}
|
|
53
|
+
const e=JSON.parse(data)
|
|
54
|
+
if(!e||e.error) return failure()
|
|
55
|
+
id=e.response_message_id??e.message_id??id;sessionId=e.chat_session_id??sessionId
|
|
56
|
+
if(Array.isArray(e.choices)){
|
|
57
|
+
if(mode&&mode!=='openai')return failure();mode='openai'
|
|
58
|
+
const c=e.choices[0]
|
|
59
|
+
if(typeof c?.delta?.content==='string'){text+=c.delta.content;finished=false}
|
|
60
|
+
if(c?.finish_reason){if(c.finish_reason!=='stop')return failure();finished=true}
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if(e.v?.response){mode='deepseek';response(e.v.response);continue}
|
|
64
|
+
if(typeof e.p==='string'){
|
|
65
|
+
path=e.p
|
|
66
|
+
// A new explicit path without o defaults to APPEND; bare v reuses the last path/op.
|
|
67
|
+
operation=e.o??'APPEND'
|
|
68
|
+
}else if(typeof e.o==='string')operation=e.o
|
|
69
|
+
if(Object.hasOwn(e,'v'))patch(path,operation,e.v)
|
|
70
|
+
}
|
|
71
|
+
if(mode==='deepseek')text=fragments.filter(f=>f.type==='RESPONSE').map(f=>f.content).join('')
|
|
72
|
+
return mode&&finished&&text.length?{ok:true,content:text,id,sessionId}:failure()
|
|
73
|
+
}catch{return failure()}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decodeArtifactHistory(payload,{sessionId,pagePath}={}) {
|
|
77
|
+
const data=payload?.data?.biz_data??payload?.biz_data??payload?.data??payload
|
|
78
|
+
if(!data||(payload?.data?.biz_code && payload.data.biz_code!==0))return []
|
|
79
|
+
const sid=data.chat_session?.id??data.chat_session_id??sessionId
|
|
80
|
+
if(sessionId!=null&&sid!=null&&String(sid)!==String(sessionId))return []
|
|
81
|
+
const messages=data.chat_messages??data.messages
|
|
82
|
+
if(!Array.isArray(messages))return []
|
|
83
|
+
return messages.flatMap(message=>{
|
|
84
|
+
const role=String(message?.message_role??message?.role??'').toLowerCase()
|
|
85
|
+
if(role!=='assistant'||message.truncated||message.is_incomplete)return []
|
|
86
|
+
if(message.status && !['FINISHED','SUCCESS','COMPLETED'].includes(String(message.status).toUpperCase()))return []
|
|
87
|
+
let content
|
|
88
|
+
if(Array.isArray(message.fragments)){
|
|
89
|
+
if(!message.fragments.every(f=>f&&['RESPONSE','THINK'].includes(f.type)&&typeof f.content==='string'))return []
|
|
90
|
+
content=message.fragments.filter(f=>f.type==='RESPONSE').map(f=>f.content).join('')
|
|
91
|
+
}else content=message.content
|
|
92
|
+
return typeof content==='string'&&content.length
|
|
93
|
+
?[{ok:true,id:message.message_id??message.id??message.messageId,sessionId:sid,pagePath,content}]:[]
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const visible=text=>String(text??'').replace(/\s+/g,' ').trim()
|
|
98
|
+
function markdownText(text){
|
|
99
|
+
return text
|
|
100
|
+
// Hidden short-memory tags affect identity only; saved source remains intact.
|
|
101
|
+
.replace(/(?:<|<)DSM:memory_write\b[\s\S]*?(?:<|<)\/DSM:memory_write(?:>|>)/gi,'')
|
|
102
|
+
.replace(/^\s*(`{3,}|~{3,})[^\n]*$/gm,'')
|
|
103
|
+
.replace(/!\[([^\]]*)\]\([^\n]*?\)/g,'$1')
|
|
104
|
+
.replace(/\[([^\]]+)\]\([^\n]*?\)/g,'$1')
|
|
105
|
+
.replace(/^ {0,3}(#{1,6}\s+|>\s?|[-+*]\s+|\d+[.)]\s+)/gm,'')
|
|
106
|
+
.replace(/(\*\*|__)([^\n]+?)\1/g,'$2')
|
|
107
|
+
.replace(/(`+)([^\n]+?)\1/g,'$2')
|
|
108
|
+
.replace(/\*([^*\n]+)\*/g,'$1')
|
|
109
|
+
.replace(/\\([\\`*_{}[\]()#+.!|>-])/g,'$1')
|
|
110
|
+
}
|
|
111
|
+
export function matchArtifactSource(sources,{id,text,pagePath}){
|
|
112
|
+
const candidates=[...sources].filter(s=>!s.pagePath||s.pagePath===pagePath)
|
|
113
|
+
if(id!=null){
|
|
114
|
+
const byId=candidates.filter(s=>s.id!=null&&String(s.id)===String(id))
|
|
115
|
+
if(byId.length===1)return byId[0]
|
|
116
|
+
}
|
|
117
|
+
const ownText=visible(text)
|
|
118
|
+
if(!ownText)return null
|
|
119
|
+
// Exact full text only: no last-answer/order/prefix guess. Page reconstruction is a separate, labeled fallback.
|
|
120
|
+
const matches=candidates.filter(s=>visible(s.content)===ownText||visible(markdownText(s.content))===ownText)
|
|
121
|
+
return matches.length&&new Set(matches.map(s=>s.content)).size===1?matches[0]:null
|
|
122
|
+
}
|
|
123
|
+
export function artifactPayload({name,content,requestId,expectedVersion}) {
|
|
124
|
+
if(typeof name!=='string'||!name.trim()||name.trim().length>200)throw new Error('请输入1–200字符的名称')
|
|
125
|
+
if(typeof content!=='string'||!content.trim())throw new Error('正文不能为空')
|
|
126
|
+
if(new TextEncoder().encode(content).length>1500000)throw new Error('正文超过1.5MB,未保存')
|
|
127
|
+
return {name:name.trim(),content,requestId,...(expectedVersion?{expectedVersion}:{})}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Reconstruct only the clicked answer's rendered content. This is not server-original Markdown.
|
|
131
|
+
// No fetch, clipboard access, page scripts or document-wide text extraction.
|
|
132
|
+
export function pageArtifactMarkdown(answer){
|
|
133
|
+
if(!answer)return ''
|
|
134
|
+
const tick=String.fromCharCode(96)
|
|
135
|
+
const tag=n=>String(n?.tagName??'').toUpperCase()
|
|
136
|
+
const children=n=>Array.from(n?.childNodes??[])
|
|
137
|
+
const attr=(n,k)=>n?.getAttribute?.(k)
|
|
138
|
+
const escape=s=>String(s).replace(/[\\*_{}[\]<>#\x60]/g,'\\$&')
|
|
139
|
+
const url=value=>{
|
|
140
|
+
try{
|
|
141
|
+
const parsed=new URL(value,answer.ownerDocument?.baseURI??'https://chat.deepseek.com/')
|
|
142
|
+
return /^(https?:|mailto:)$/.test(parsed.protocol)?parsed.href.replace(/</g,'%3C').replace(/>/g,'%3E'):''
|
|
143
|
+
}catch{return ''}
|
|
144
|
+
}
|
|
145
|
+
const excluded=n=>['BUTTON','SCRIPT','STYLE','NOSCRIPT','INPUT','TEXTAREA','SELECT'].includes(tag(n))||
|
|
146
|
+
n?.hasAttribute?.('hidden')||attr(n,'aria-hidden')==='true'||attr(n,'role')==='button'||
|
|
147
|
+
/(?:display\s*:\s*none|visibility\s*:\s*hidden)/i.test(attr(n,'style')??'')
|
|
148
|
+
const plain=n=>excluded(n)?'':n?.nodeType===3?String(n.nodeValue??''):children(n).map(plain).join('')
|
|
149
|
+
const inner=n=>children(n).reduce((text,node)=>{
|
|
150
|
+
const part=render(node)
|
|
151
|
+
// Merge adjacent block separators; keep code contents and explicit line breaks intact.
|
|
152
|
+
if(node.nodeType===3&&!part.trim()&&/\n$/.test(text))return text
|
|
153
|
+
const overlap=tag(node)==='BR'?0:Math.min(text.match(/\n+$/)?.[0].length??0,part.match(/^\n+/)?.[0].length??0)
|
|
154
|
+
return text+part.slice(overlap)
|
|
155
|
+
},'')
|
|
156
|
+
function render(n){
|
|
157
|
+
if(n?.nodeType===3)return escape(String(n.nodeValue??'').replace(/\s+/g,' '))
|
|
158
|
+
if(n?.nodeType!==1||excluded(n))return ''
|
|
159
|
+
const t=tag(n),classes=String(attr(n,'class')??'')
|
|
160
|
+
if(t==='PRE'){
|
|
161
|
+
const code=n.querySelector?.('code'),text=code?code.textContent:plain(n)
|
|
162
|
+
const fence=tick.repeat(Math.max(3,...(text.match(/\x60+/g)??[]).map(s=>s.length+1)))
|
|
163
|
+
const language=(attr(code,'class')??'').match(/(?:^|\s)language-([a-z0-9_+-]+)/i)?.[1]??''
|
|
164
|
+
return '\n\n'+fence+language+'\n'+text+(text.endsWith('\n')?'':'\n')+fence+'\n\n'
|
|
165
|
+
}
|
|
166
|
+
if(/\bkatex(?:-display)?\b/.test(classes)||t==='MATH'){
|
|
167
|
+
const tex=n.querySelector?.('annotation[encoding="application/x-tex"]')?.textContent
|
|
168
|
+
if(tex)return classes.includes('katex-display')?'\n\n$$\n'+tex+'\n$$\n\n':'$'+tex+'$'
|
|
169
|
+
}
|
|
170
|
+
if(t==='CODE'){
|
|
171
|
+
const text=plain(n),fence=tick.repeat(Math.max(1,...(text.match(/\x60+/g)??[]).map(s=>s.length+1)))
|
|
172
|
+
return fence+' '+text+' '+fence
|
|
173
|
+
}
|
|
174
|
+
if(t==='BR')return '\n'
|
|
175
|
+
if(t==='HR')return '\n\n---\n\n'
|
|
176
|
+
if(t==='SVG'||t==='CANVAS')return '[页面图形:请核对原页面]'
|
|
177
|
+
if(t==='IMG'){
|
|
178
|
+
const src=url(attr(n,'src')),alt=escape(attr(n,'alt')??'图片')
|
|
179
|
+
return src?'':'['+alt+']'
|
|
180
|
+
}
|
|
181
|
+
if(t==='TABLE'){
|
|
182
|
+
const rows=[]
|
|
183
|
+
const walk=node=>{for(const child of children(node)){if(tag(child)==='TR')rows.push(children(child).filter(c=>['TD','TH'].includes(tag(c))));else if(['THEAD','TBODY','TFOOT'].includes(tag(child)))walk(child)}}
|
|
184
|
+
walk(n)
|
|
185
|
+
if(!rows.length)return inner(n)
|
|
186
|
+
const width=Math.max(...rows.map(r=>r.length)),line=cells=>'| '+Array.from({length:width},(_,i)=>cells[i]?inner(cells[i]).trim().replace(/\|/g,'\\|').replace(/\n+/g,'<br>'):'').join(' | ')+' |'
|
|
187
|
+
const caption=children(n).find(c=>tag(c)==='CAPTION')
|
|
188
|
+
return '\n\n'+(caption?inner(caption)+'\n\n':'')+line(rows[0])+'\n| '+Array(width).fill('---').join(' | ')+' |\n'+rows.slice(1).map(line).join('\n')+'\n\n'
|
|
189
|
+
}
|
|
190
|
+
if(t==='UL'||t==='OL'){
|
|
191
|
+
const start=Number(attr(n,'start'))||1
|
|
192
|
+
return '\n'+children(n).filter(c=>tag(c)==='LI').map((li,i)=>{
|
|
193
|
+
const prefix=t==='OL'?String(start+i)+'. ':'- '
|
|
194
|
+
return prefix+inner(li).trim().replace(/\n/g,'\n'+' '.repeat(prefix.length))
|
|
195
|
+
}).join('\n')+'\n'
|
|
196
|
+
}
|
|
197
|
+
const content=inner(n)
|
|
198
|
+
if(/^H[1-6]$/.test(t))return '\n\n'+'#'.repeat(Number(t[1]))+' '+content.trim()+'\n\n'
|
|
199
|
+
if(t==='STRONG'||t==='B')return '**'+content+'**'
|
|
200
|
+
if(t==='EM'||t==='I')return '*'+content+'*'
|
|
201
|
+
if(t==='DEL'||t==='S')return '~~'+content+'~~'
|
|
202
|
+
if(t==='A'){const href=url(attr(n,'href'));return href?'['+content+'](<'+href+'>)':content}
|
|
203
|
+
if(t==='BLOCKQUOTE')return '\n\n'+content.trim().split('\n').map(l=>'> '+l).join('\n')+'\n\n'
|
|
204
|
+
if(['P','DIV','SECTION','ARTICLE','DETAILS'].includes(t))return '\n\n'+content.trim()+'\n\n'
|
|
205
|
+
return content
|
|
206
|
+
}
|
|
207
|
+
return inner(answer).trim()
|
|
208
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Shared wire version: ignores access timestamps; includes every editable field.
|
|
2
|
+
// No Node imports: extension and adapter use the same fingerprint.
|
|
3
|
+
export function entryIdentity(item) {
|
|
4
|
+
return JSON.stringify([item?.id ?? null, item?.key ?? null, item?.content ?? '',
|
|
5
|
+
item?.injectionPolicy === 'universal' ? 'universal' : 'manual', item?.kind ?? 'fact', item?.revision ?? null])
|
|
6
|
+
}
|
|
7
|
+
export function injectionPolicy(item) { return item?.injectionPolicy === 'universal' ? 'universal' : 'manual' }
|
package/extension/core/queue.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 序列化:入队与 flush 的「读-裁-写」都串到同一条 promise 链上(队列变更单一执行者),
|
|
4
4
|
// 网络调用保持在链外(退避等待不能卡链)。并发 push 不丢条目,失败保序不越队。
|
|
5
5
|
|
|
6
|
-
export function createOfflineQueue({ storage, fetchImpl, bridgeUrl, token = '', maxRetries = 6, baseDelayMs = 1000 }) {
|
|
6
|
+
export function createOfflineQueue({ storage, fetchImpl, bridgeUrl, token = '', refreshToken, maxRetries = 6, baseDelayMs = 1000 }) {
|
|
7
7
|
const KEY = 'ecolink_outbox'
|
|
8
8
|
let flushing = false
|
|
9
9
|
let chain = Promise.resolve()
|
|
@@ -18,14 +18,15 @@ export function createOfflineQueue({ storage, fetchImpl, bridgeUrl, token = '',
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
async function postToBridge(entry) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
headers,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
21
|
+
const body = JSON.stringify(entry.payload)
|
|
22
|
+
const send = async value => {
|
|
23
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
24
|
+
if (value) headers['X-Ecolink-Token'] = value
|
|
25
|
+
return fetchImpl(bridgeUrl.replace(/\/$/, '')+'/memory/'+(entry.kind === 'session' ? 'session' : entry.kind === 'touch' ? 'touch' : 'sync'), {method:'POST', headers, body})
|
|
26
|
+
}
|
|
27
|
+
let resp = await send(typeof token === 'function' ? await token() : token)
|
|
28
|
+
if (resp.status === 401 && refreshToken) resp = await send(await refreshToken())
|
|
29
|
+
if (!resp.ok) throw new Error('bridge '+resp.status)
|
|
29
30
|
return resp.json()
|
|
30
31
|
}
|
|
31
32
|
|