dsh-design-studio 0.11.0 → 0.13.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/lib/core.js CHANGED
@@ -10,12 +10,14 @@ import fs from 'node:fs'
10
10
  import os from 'node:os'
11
11
  import path from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
+ import { createJsonStringDecoder } from './draft.js'
13
14
 
14
15
  const HTML_EXT = /\.html?$/i
15
16
  const LOCAL_URL = /^(?:https?:\/\/)?(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})(\/[^\s]*)?$/i
16
17
  // 命令输出里的本机地址(开发服务器启动时会打印)
17
18
  const URL_IN_TEXT = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})(\/[^\s"'`<>)\]]*)?/gi
18
19
  const LIVE_MS = 120000 // 事件时间离现在超过这么久就当是重放历史
20
+ const DRAFT_LIVE_MS = 8000 // 草稿写完这么久以内右侧栏才看到:还放一遍(轮询 0.7 秒一次,放一遍三四秒,留足余量)
19
21
  const CHANGE_LOG = 20 // 记最近几批文件改动(远程看按它补齐两次轮询之间的改动)
20
22
  const DESIGN_SKILL = 'ui-design'
21
23
  const ASK_TOOL = 'ask_user_question' // DSH 的提问卡:模型问了、在等用户回答
@@ -196,8 +198,10 @@ export function sameTarget(a, b) {
196
198
  }
197
199
 
198
200
  /** 工具参数(JSON 字符串或对象)里的写文件路径;不是写文件返回 '' */
201
+ const WRITE_TOOL = /^(write|edit|multi_?edit|create_file|str_replace\w*)$/i
202
+
199
203
  function writtenPath(name, args) {
200
- if (!/^(write|edit|multi_?edit|create_file|str_replace\w*)$/i.test(String(name || ''))) return ''
204
+ if (!WRITE_TOOL.test(String(name || ''))) return ''
201
205
  let a = args
202
206
  if (typeof a === 'string') {
203
207
  try { a = JSON.parse(a) } catch { return '' }
@@ -544,7 +548,7 @@ export function askOf(data) {
544
548
  /**
545
549
  * @param opts.alive (origin) → 这个预览服务器还开着吗(开的太多时旧的会被关掉;发现没了就重开)
546
550
  */
547
- export function createTracker({ open, skipPorts = () => new Set(), now = Date.now, log = () => {}, store = null, history = null, alive = () => true, autoOpen = () => true, beforeRestore = () => {} }) {
551
+ export function createTracker({ open, drafts = null, skipPorts = () => new Set(), now = Date.now, log = () => {}, store = null, history = null, alive = () => true, autoOpen = () => true, beforeRestore = () => {} }) {
548
552
  const states = new Map() // sessionId → state
549
553
  const cwds = new Map() // sessionId → 最近一次工具的工作目录
550
554
  let saved = {}
@@ -673,6 +677,8 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
673
677
  if (size !== undefined) st.size = parseSize(size)
674
678
  if (view !== undefined) st.view = view === 'canvas' || view === 'single' ? view : ''
675
679
  if (routes !== undefined) st.routes = normRoutes(routes)
680
+ // 换到别的目录了:正在写的草稿是原来那套的,不再报(不然新设计里同名的那屏会被它挡住)
681
+ if (st.draft && !(target.kind === 'file' && st.draft.root === target.root)) st.draft = null
676
682
  st.target = target
677
683
  st.origin = origin
678
684
  st.url = urlFor(target, origin)
@@ -713,6 +719,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
713
719
  function clear(id) {
714
720
  const st = stateOf(id)
715
721
  st.target = null
722
+ st.draft = null
716
723
  st.url = ''
717
724
  st.label = ''
718
725
  st.source = ''
@@ -743,6 +750,165 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
743
750
  setTarget(id, target, { source: 'auto' }).catch((e) => log(e))
744
751
  }
745
752
 
753
+ const absOf = (sessionId, p) => path.resolve(cwdOf(sessionId), p.replace(/^~(?=\/|$)/, os.homedir()))
754
+ // 状态条说「正在写 x」的文件:在现在这套设计的文件夹里;还没有设计时,写什么都算(第一屏还在路上)
755
+ const ownsFile = (st, abs) => !st.target || (st.target.kind === 'file' && (abs === st.target.file || abs.startsWith(st.target.root + path.sep)))
756
+
757
+ /**
758
+ * 模型边说边推的流(DSH 的 agent/assistant-stream)。写文件时参数要流好几分钟才到 tool/call,
759
+ * 这里提前从半截参数里认出工具名和文件名,状态条就不会一直「在想」;顺手数一下写了多少行。
760
+ */
761
+ function stream(sessionId, frame) {
762
+ const st = sessionId && frame ? states.get(sessionId) : null
763
+ if (!st || !st.turn) return
764
+ if (frame.type === 'start' || frame.type === 'end') {
765
+ // 这一次说完了:没写完的页面收尾(在看的 iframe 停在写到的地方)
766
+ if (st.parts) for (const part of st.parts.values()) if (part.draft && drafts) drafts.end(part.draft, part.did)
767
+ st.parts = null
768
+ if (frame.type === 'start') {
769
+ st.tool = ''
770
+ st.writing = ''
771
+ st.lines = 0
772
+ // 写完落了地的留着(右侧栏靠它认出「刚才看着写的就是这屏」,单屏好换过去);没写完的作废
773
+ if (st.draft && !(st.draft.file && fs.existsSync(st.draft.file))) st.draft = null
774
+ }
775
+ return
776
+ }
777
+ const c = frame.type === 'chunk' ? frame.chunk : null
778
+ if (!c || c.type !== 'tool-call-delta') return
779
+ const parts = st.parts || (st.parts = new Map())
780
+ let part = parts.get(c.index)
781
+ if (!part) {
782
+ part = { name: '', head: '', tail: '', file: '', lines: 0, last: '', abs: '', dec: null, draft: '', did: 0, prov: '' }
783
+ parts.set(c.index, part)
784
+ if (parts.size > 16) {
785
+ // 挤掉最早的一个:先挑没在写页面的;挤掉的草稿收个尾,别让在看的一直挂着
786
+ let k = null
787
+ for (const [i, p] of parts) if (i !== c.index && !(p.draft && p.dec && !p.dec.done)) { k = i; break }
788
+ if (k === null) for (const i of parts.keys()) if (i !== c.index) { k = i; break }
789
+ const old = parts.get(k)
790
+ if (old && old.draft && drafts) drafts.end(old.draft, old.did) // 只收它自己那份:同一个文件后来又开始重写的不动
791
+ parts.delete(k)
792
+ }
793
+ }
794
+ if (c.name) part.name = String(c.name)
795
+ if (!part.name) return
796
+ st.tool = part.name
797
+ if (!WRITE_TOOL.test(part.name)) return
798
+ const delta = typeof c.argumentsDelta === 'string' ? c.argumentsDelta : ''
799
+ if (part.dec) {
800
+ // 页面正文在流:还原出来的一截直接推给在看的预览
801
+ const was = part.dec.done
802
+ const text = part.dec.push(delta)
803
+ if (text && part.draft && drafts) drafts.push(part.draft, text, part.did)
804
+ if (part.dec.done) {
805
+ if (!was && part.draft && drafts) drafts.end(part.draft, part.did)
806
+ // 有的模型把正文排在文件名前面(DeepSeek V4.1 就这样):正文写完了,接着从后面认文件名
807
+ if (!part.file) {
808
+ part.tail = (part.tail + delta).slice(-16384) // 只留最后一段:文件名在正文后面,前面再大的一块也挤不掉它
809
+ const p = pathIn(part.tail)
810
+ if (p) settle(sessionId, st, part, absOf(sessionId, p))
811
+ }
812
+ }
813
+ } else if (!part.skip && part.head.length < 8192 && part.file !== '-') {
814
+ part.head += delta
815
+ if (!part.file) {
816
+ const p = pathIn(part.head)
817
+ if (p) settle(sessionId, st, part, absOf(sessionId, p))
818
+ }
819
+ // 整页写(write 的 content / create 的 file_text):开一份草稿,边写边给预览
820
+ const body = part.file !== '-' && drafts ? /"(?:content|file_text)"\s*:\s*"/.exec(part.head) : null
821
+ if (body) startBody(sessionId, st, part, part.head.slice(body.index + body[0].length))
822
+ }
823
+ // 参数是 JSON:内容里的换行是两个字 \n;跨块切开的也算上
824
+ const seen = (part.last + delta).match(/\\n/g)
825
+ part.lines += seen ? seen.length : 0
826
+ part.last = delta.slice(-1)
827
+ if (part.file !== '-') {
828
+ if (part.file) st.writing = part.file
829
+ st.lines = part.lines // 文件名还没到(正文在前)也数行:状态条和占位里的鼠标都靠它动
830
+ }
831
+ }
832
+
833
+ const pathIn = (text) => {
834
+ const m = /"(?:file_path|filePath|path)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text)
835
+ if (!m) return ''
836
+ try { return JSON.parse(`"${m[1]}"`) } catch { return m[1] }
837
+ }
838
+
839
+ /** 认出了这次写的是哪个文件:记下;正文在前、草稿先挂在临时名字上的,现在对上真名字(不是一屏就作废) */
840
+ function settle(sessionId, st, part, abs) {
841
+ part.abs = abs
842
+ part.file = ownsFile(st, abs) ? path.basename(abs) : '-'
843
+ if (part.file !== '-') st.lastDir = path.dirname(abs)
844
+ if (!part.draft || !part.prov) return
845
+ const d = st.draft
846
+ const cur = !!d && d.serve === part.draft && !d.file
847
+ if (part.file !== '-' && HTML_EXT.test(abs) && path.dirname(abs) === part.prov && !/^[._]/.test(part.file)) {
848
+ if (cur) d.file = abs
849
+ } else {
850
+ // 不是一屏(子文件夹里的、探针、样式表):作废,临时地址也读不到了(右侧栏已经在看后面那份的也作废)
851
+ if (cur) st.draft = null
852
+ if (drafts) drafts.drop(part.draft, part.did)
853
+ part.draft = ''
854
+ }
855
+ part.prov = ''
856
+ }
857
+
858
+ let provisional = 0
859
+ /**
860
+ * 正文开始流了:要不要开草稿。名字先到的看名字(这套设计第一层的网页);正文先到的看开头像不像网页,
861
+ * 先挂在设计文件夹里一个临时名字上(相对地址照样对得上),名字到了再对上。
862
+ * 还没有设计(第一屏)时先给这个文件夹开预览服务器,草稿从那里看。
863
+ */
864
+ function startBody(sessionId, st, part, rest) {
865
+ const known = !!part.file
866
+ const over = /(?:^|[^\\])(?:\\\\)*"/.test(rest) // 正文已经流完了(碰到没转义的引号):不用再等
867
+ if (!known && rest.length < 64 && !over) return // 正文先到:多攒一点再认是不是网页
868
+ let ok = known ? HTML_EXT.test(part.abs) : /^\s*<(?:!doctype\s+html|html)\b/i.test(rest)
869
+ if (ok && !st.target && !st.design) {
870
+ // 页面开头自己写了设计类型标记(<meta name="dsh-design">):也是在做设计
871
+ if (/<meta[^>]*dsh-design/i.test(rest)) st.design = true
872
+ else if (rest.length <= 3000 && !over) return // 还在开头:等等标记
873
+ else ok = false // 开头这么长还没有标记:不是在做设计(比如写代码的会话写了个网页),不开
874
+ }
875
+ const root = !ok ? '' : st.target && st.target.kind === 'file' ? st.target.root : known ? path.dirname(part.abs) : st.lastDir || ''
876
+ // 只给「一屏」开:设计文件夹第一层的网页,_ / . 开头的是模型的探针、草稿,不算
877
+ if (ok && known && (path.dirname(part.abs) !== root || /^[._]/.test(part.file))) ok = false
878
+ if (ok && !root) ok = false
879
+ if (known && !ok) {
880
+ part.skip = true
881
+ return
882
+ }
883
+ // 正文先到的不开草稿也要跟着还原:好知道正文在哪儿结束,后面才认得出文件名
884
+ part.dec = createJsonStringDecoder()
885
+ part.tail = ''
886
+ let draft = null
887
+ if (ok) {
888
+ const serve = known ? part.abs : path.join(root, `__ddp_draft_${++provisional}__.html`)
889
+ part.draft = serve
890
+ part.prov = known ? '' : root // 挂在临时名字上的:记着它属于哪个文件夹,名字到了自己核对(不管这时右侧栏在看哪份)
891
+ draft = { file: known ? part.abs : '', serve, root, id: drafts.begin(serve), origin: st.target ? st.origin : '' }
892
+ part.did = draft.id // 草稿编号:之后推、收尾、作废都核对它(同一个文件又开始重写时,旧的不碰新的)
893
+ st.draft = draft
894
+ }
895
+ const text = part.dec.push(rest)
896
+ if (draft && text) drafts.push(draft.serve, text, draft.id)
897
+ if (part.dec.done) {
898
+ if (draft) drafts.end(draft.serve, draft.id)
899
+ if (!known) {
900
+ part.tail = rest // 正文里的引号都转义过(\"),认文件名不会认到正文里去
901
+ const p = pathIn(rest)
902
+ if (p) settle(sessionId, st, part, absOf(sessionId, p))
903
+ }
904
+ }
905
+ if (draft && !draft.origin) {
906
+ open({ kind: 'file', file: draft.serve, root }).then((r) => {
907
+ if (st.draft === draft) draft.origin = r.origin
908
+ }, (e) => log(e))
909
+ }
910
+ }
911
+
746
912
  /** 会话事件 → 状态;自动发现只在没有目标、或目标也是自动发现的时候动手 */
747
913
  function observe(sessionId, event) {
748
914
  if (!sessionId || !event) return
@@ -759,12 +925,21 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
759
925
  case 'turn/start':
760
926
  if (live) {
761
927
  st.turn = true
928
+ st.turnAt = now() // 右侧栏的状态条从这里开始计时
929
+ st.tool = ''
762
930
  keepVersion(st, '')
763
931
  }
764
932
  break
765
933
  case 'turn/end':
766
934
  st.turn = false
935
+ st.turnAt = 0
936
+ st.tool = ''
767
937
  st.writing = ''
938
+ st.lines = 0
939
+ if (st.parts && drafts) for (const part of st.parts.values()) if (part.draft) drafts.end(part.draft, part.did)
940
+ st.parts = null
941
+ // 最后一屏整页一下子到、马上就说完了:落了地的草稿留着(快照里刚写完的几秒还报,右侧栏放完它)
942
+ if (!(st.draft && st.draft.file && fs.existsSync(st.draft.file))) st.draft = null
768
943
  st.asking = false
769
944
  if (live) {
770
945
  keepVersion(st, st.ask)
@@ -778,15 +953,17 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
778
953
  if (d.name === 'preview_page') st.design = true
779
954
  if (!live) break
780
955
  if (d.name === ASK_TOOL) st.asking = true
956
+ st.tool = String(d.name || '') // 状态条按它说「正在体检」「正在跑命令」…
781
957
  if (d.callId) {
782
958
  st.calls.set(d.callId, { name: d.name, args: argsOf(d.arguments) })
783
959
  if (st.calls.size > 64) st.calls.delete(st.calls.keys().next().value)
784
960
  }
785
961
  const p = writtenPath(d.name, d.arguments)
786
962
  if (!p) break
787
- const abs = path.resolve(cwdOf(sessionId), p.replace(/^~(?=\/|$)/, os.homedir()))
788
- if (st.target && st.target.kind === 'file' && (abs === st.target.file || abs.startsWith(st.target.root + path.sep))) {
963
+ const abs = absOf(sessionId, p)
964
+ if (ownsFile(st, abs)) {
789
965
  st.writing = path.basename(abs)
966
+ st.lastDir = path.dirname(abs) // 下一页要是正文先到,草稿先挂在这个文件夹里
790
967
  }
791
968
  if (HTML_EXT.test(abs) && (!st.target || (st.source === 'auto' && st.target.kind === 'file'))) {
792
969
  st.pendingHtml = abs // 写完(tool/result)再预览,免得打开一个还不存在的文件
@@ -799,6 +976,8 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
799
976
  if (st.target && saved[sessionId] && saved[sessionId].cwd !== d.meta.cwd) persist(sessionId, st)
800
977
  }
801
978
  st.writing = ''
979
+ st.lines = 0
980
+ st.tool = ''
802
981
  if (!live) break
803
982
  const pending = st.pendingHtml
804
983
  st.pendingHtml = ''
@@ -934,11 +1113,32 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
934
1113
  return r
935
1114
  }
936
1115
 
1116
+ /** 正在写的那页:预览服务器上的草稿地址 + 它在设计里的名字(和 screens 的 key 一样) */
1117
+ function draftOf(st) {
1118
+ const d = st ? st.draft : null
1119
+ if (!d || !d.origin) return null
1120
+ const relOf = (f) => path.relative(d.root, f).split(path.sep).join('/')
1121
+ const sv = relOf(d.serve || d.file)
1122
+ if (!sv || sv.startsWith('..')) return null
1123
+ // 就挂在这一屏自己的地址上,带个记号(serve.js 认它):页面里的相对地址照常对得上。
1124
+ // 正文先到的先挂在临时名字上,key 空着(还不知道是哪屏),名字到了再填。
1125
+ // live:还在写、或刚写完(右侧栏这时才看到也值得放一遍:整页一下子到的,预览服务器会一截截放)
1126
+ const rec = drafts && drafts.get(d.serve)
1127
+ const live = !!rec && rec.id === d.id && (!rec.done || Date.now() - (rec.endAt || 0) < DRAFT_LIVE_MS)
1128
+ if (!st.turn && !live) return null // 这一轮说完了:只报刚写完的那份
1129
+ // 页面开头写的设计类型(<meta name="dsh-design" content="miniprogram 390x844">):真文件还没摆出来时,右侧栏靠它套对外框
1130
+ const mt = rec && /<meta\b[^>]*\bname\s*=\s*["']?dsh-design\b[^>]*>/i.exec(rec.text.slice(0, 6000))
1131
+ const mc = mt && /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(mt[0])
1132
+ // lump:写完了、而且是整段一下子到的(右侧栏放完这份再换真文件;一路流着写的落地就换)
1133
+ return { key: d.file ? relOf(d.file) : '', url: `${d.origin}/${sv.split('/').map(encodeURIComponent).join('/')}?__ddp_draft=${d.id}`, live, lump: !!(live && rec.done && rec.lump), meta: mc ? (mc[1] ?? mc[2] ?? '').slice(0, 80) : '' }
1134
+ }
1135
+
937
1136
  function snapshot(id) {
938
1137
  const st = states.get(id)
939
1138
  if (!(st && st.target)) restore(id)
940
1139
  else if (st.origin && !alive(st.origin)) revive(id, st)
941
- if (!st || !st.target) return { target: null, rev: st ? st.rev : 0, openRev: st ? st.openRev : 0, turn: st ? st.turn : false, turns: st ? st.turns || 0 : 0, asking: !!(st && st.asking), writing: '', hint: st && st.hint && fs.existsSync(st.hint) ? st.hint : '', loading: restoring.has(id) }
1140
+ const turnFor = st && st.turn && st.turnAt ? Math.max(0, now() - st.turnAt) : 0
1141
+ if (!st || !st.target) return { target: null, rev: st ? st.rev : 0, openRev: st ? st.openRev : 0, turn: st ? st.turn : false, turnFor, tool: (st && st.turn && st.tool) || '', turns: st ? st.turns || 0 : 0, asking: !!(st && st.asking), writing: (st && st.turn && st.writing) || '', lines: (st && st.turn && st.lines) || 0, draft: draftOf(st), hint: st && st.hint && fs.existsSync(st.hint) ? st.hint : '', loading: restoring.has(id) }
942
1142
  const { target, url, label, source, rev, openRev, turn, writing, changedAt, kind, size, view, origin } = st
943
1143
  const file = target.kind === 'file'
944
1144
  return {
@@ -949,9 +1149,13 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
949
1149
  rev,
950
1150
  openRev,
951
1151
  turn,
1152
+ turnFor,
1153
+ tool: (turn && st.tool) || '',
952
1154
  turns: st.turns || 0,
953
1155
  asking: !!st.asking,
954
1156
  writing,
1157
+ lines: (turn && st.lines) || 0,
1158
+ draft: draftOf(st),
955
1159
  changedAt,
956
1160
  changedFiles: st.changedFiles || [],
957
1161
  changes: st.changes || [],
@@ -969,5 +1173,5 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
969
1173
  }
970
1174
  }
971
1175
 
972
- return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, editStyle, editDir, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
1176
+ return { observe, stream, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, editStyle, editDir, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
973
1177
  }