File size: 16,264 Bytes
06c3755
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
// KnowledgePanel:知识卡片独立大面板(用户拍板 2026-09-10:形态 A 独立
// overlay 双栏;数据 = 全局单库 wiki,工作区只是卡片上的元数据标签)。
//
// 两种建卡路径:
//   1. 从工作区生成(主路径,用户指定):输入现有工作区路径 → wiki.generate
//      → 宿主跑 wiki agent 阅读工作区文档与代码后产卡(进度经
//      wiki.generateStatus 出站信号投影到 store.wikiGen)。
//   2. 手动新建:表单直填 type/title/summary/content。
//
// 数据面全走 gate 命令(前后端只有信号关系):
//   - 列表   → wiki.listCards(wiki server slim 卡片:id/title/type/summary)
//   - 预览   → wiki.renderCard → 宿主 render-md 渲染 HTML → iframe srcdoc
//             (渲染基座唯一渲染管线,与 ArtifactTab 同款沙箱形态)
//   - 创建   → wiki.addCard(id 由宿主生成)
// wiki.changed 广播(生成任务完成 / 手动创建 / 其他客户端变更)→ 重拉列表。

import { useCallback, useEffect, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { command } from '../../api/token'
import { useSessionStore } from '../../state/session-store'
import { recentWorkDirs } from '../overlays/NewSessionDialog'
import type { WikiCardSummary } from '../../api/contract'

const TYPE_LABELS: Record<string, string> = {
  module: '模块',
  interface: '接口',
  function: '函数',
  class: '类',
  pattern: '模式',
  concept: '概念',
}

const EMPTY_CARDS: WikiCardSummary[] = []

type Props = {
  open: boolean
  onOpenChange: (open: boolean) => void
}

export default function KnowledgePanel({ open, onOpenChange }: Props) {
  const wikiChangedAt = useSessionStore((s) => s.wikiChangedAt)
  const wikiGen = useSessionStore((s) => s.wikiGen)
  const [cards, setCards] = useState<WikiCardSummary[]>(EMPTY_CARDS)
  const [error, setError] = useState<string | null>(null)
  const [selectedId, setSelectedId] = useState<string | null>(null)
  const [preview, setPreview] = useState<{ title: string; html: string } | null>(null)
  const [previewLoading, setPreviewLoading] = useState(false)
  const [creating, setCreating] = useState(false)
  // 生成区(从工作区生成)
  const [genPath, setGenPath] = useState('')
  // 表单字段(创建模式)
  const [title, setTitle] = useState('')
  const [type, setType] = useState('concept')
  const [summary, setSummary] = useState('')
  const [content, setContent] = useState('')

  const generating = wikiGen?.status === 'started'
  const recent = recentWorkDirs()

  const refresh = useCallback(async () => {
    try {
      const res = await command({ kind: 'wiki.listCards' })
      setError(null)
      setCards(
        res.ok === true && res.result !== null && typeof res.result === 'object' && 'cards' in res.result
          ? (res.result as { cards: WikiCardSummary[] }).cards
          : EMPTY_CARDS,
      )
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e))
    }
  }, [])

  useEffect(() => {
    if (open) void refresh()
  }, [open, refresh])

  // wiki.changed(本面板创建成功或其他客户端写入)→ 重拉列表。
  useEffect(() => {
    if (open && wikiChangedAt > 0) void refresh()
  }, [open, wikiChangedAt, refresh])

  const openCard = async (card: WikiCardSummary) => {
    setSelectedId(card.id)
    setCreating(false)
    setPreviewLoading(true)
    try {
      const res = await command({ kind: 'wiki.renderCard', cardId: card.id })
      if (res.ok === true && res.result !== null && typeof res.result === 'object' && 'html' in res.result) {
        const r = res.result as { title: string; html: string }
        setPreview({ title: r.title, html: r.html })
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e))
    } finally {
      setPreviewLoading(false)
    }
  }

  const submit = async () => {
    if (title.trim().length === 0 || summary.trim().length === 0) return
    try {
      const res = await command({
        kind: 'wiki.addCard',
        card: {
          type,
          title: title.trim(),
          summary: summary.trim(),
          content,
        },
      })
      const id =
        res.ok === true && res.result !== null && typeof res.result === 'object' && 'id' in res.result
          ? (res.result as { id: string }).id
          : null
      setTitle('')
      setSummary('')
      setContent('')
      setCreating(false)
      // 列表刷新由 wiki.changed 广播驱动(宿主 emit);这里直接定位新卡预览。
      if (id !== null) {
        setSelectedId(id)
        setPreviewLoading(true)
        try {
          const r2 = await command({ kind: 'wiki.renderCard', cardId: id })
          if (r2.ok === true && r2.result !== null && typeof r2.result === 'object' && 'html' in r2.result) {
            const r = r2.result as { title: string; html: string }
            setPreview({ title: r.title, html: r.html })
          }
        } finally {
          setPreviewLoading(false)
        }
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e))
    }
  }

  const startGenerate = async () => {
    const dir = genPath.trim()
    if (dir.length === 0 || generating) return
    try {
      await command({ kind: 'wiki.generate', workDir: dir })
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e))
    }
  }

  const inputStyle = { borderColor: 'var(--border)', background: 'var(--bg)' }

  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 z-40" style={{ background: 'rgba(15,20,30,0.45)' }} />
        <Dialog.Content
          className="fixed left-1/2 top-1/2 z-50 w-[920px] max-w-[95vw] h-[640px] max-h-[90vh] -translate-x-1/2 -translate-y-1/2 rounded-xl border flex flex-col overflow-hidden"
          style={{ borderColor: 'var(--border)', background: 'var(--bg-elevated)' }}
        >
          <div className="px-5 py-3 border-b flex items-center justify-between shrink-0" style={{ borderColor: 'var(--border)' }}>
            <div>
              <Dialog.Title className="font-semibold">知识卡片</Dialog.Title>
              <p className="text-[11px] mt-0.5" style={{ color: 'var(--text-dim)' }}>
                全局单库 · wiki —— 选择一个工作区,wiki agent 会阅读其文档与代码生成知识库
              </p>
            </div>
            <Dialog.Close asChild>
              <button className="text-sm px-2 hover:opacity-70" style={{ color: 'var(--text-dim)' }} title="关闭">
                ✕
              </button>
            </Dialog.Close>
          </div>

          {/* 从工作区生成(主路径,用户指定):路径 + 生成按钮 + 进度状态。 */}
          <div
            className="px-5 py-2.5 border-b flex items-center gap-2 flex-wrap shrink-0"
            style={{ borderColor: 'var(--border)', background: 'var(--bg)' }}
          >
            <input
              className="flex-1 min-w-[260px] text-sm rounded-md border px-3 py-1.5 outline-none font-mono"
              style={inputStyle}
              placeholder="工作区绝对路径(如 D:\projects\my-app)——wiki agent 阅读后生成知识卡片"
              list="knowledge-gen-workdirs"
              value={genPath}
              onChange={(e) => setGenPath(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && void startGenerate()}
            />
            <datalist id="knowledge-gen-workdirs">
              {recent.map((d) => (
                <option key={d} value={d} />
              ))}
            </datalist>
            <button
              className="text-sm px-4 py-1.5 rounded-md shrink-0"
              style={{
                background: 'var(--accent)',
                color: '#fff',
                opacity: generating || genPath.trim().length === 0 ? 0.4 : 1,
                cursor: generating ? 'wait' : 'pointer',
              }}
              disabled={generating || genPath.trim().length === 0}
              onClick={() => void startGenerate()}
            >
              {generating ? '生成中…' : '生成知识库'}
            </button>
            {wikiGen !== undefined && (
              <span
                className="text-xs w-full truncate"
                title={
                  wikiGen.status === 'started'
                    ? wikiGen.workDir
                    : wikiGen.status === 'completed'
                      ? `生成完成:新增 ${wikiGen.cardsCreated} 张卡片(${wikiGen.workDir})`
                      : wikiGen.error
                }
                style={{
                  color:
                    wikiGen.status === 'failed'
                      ? 'var(--err)'
                      : wikiGen.status === 'completed'
                        ? 'var(--accent)'
                        : 'var(--warn)',
                }}
              >
                {wikiGen.status === 'started' && `🔄 正在生成:${wikiGen.workDir}`}
                {wikiGen.status === 'completed' &&
                  `✓ 生成完成:新增 ${wikiGen.cardsCreated} 张卡片(${wikiGen.workDir})`}
                {wikiGen.status === 'failed' && `✗ 生成失败:${wikiGen.error}`}
              </span>
            )}
          </div>

          <div className="flex flex-1 min-h-0">
            {/* 左栏:新建入口 + 卡片列表 */}
            <div className="w-64 shrink-0 border-r flex flex-col min-h-0" style={{ borderColor: 'var(--border)' }}>
              <div className="p-2 shrink-0">
                <button
                  className="w-full text-sm px-3 py-1.5 rounded-md border hover:opacity-80"
                  style={{
                    borderColor: creating ? 'var(--accent)' : 'var(--border)',
                    background: 'var(--bg)',
                    color: creating ? 'var(--accent)' : 'var(--text)',
                  }}
                  onClick={() => {
                    setCreating(!creating)
                    setSelectedId(null)
                  }}
                >
                  + 新建卡片
                </button>
              </div>
              <div className="flex-1 overflow-y-auto min-h-0 px-2 pb-2 space-y-0.5">
                {cards.length === 0 && !creating && (
                  <p className="text-xs px-3 py-3" style={{ color: 'var(--text-dim)' }}>
                    {error ?? '知识库是空的——新建一张卡片开始。'}
                  </p>
                )}
                {cards.map((c) => {
                  const selected = selectedId === c.id
                  return (
                    <button
                      key={c.id}
                      className="w-full text-left text-xs px-3 py-2 rounded-md hover:opacity-80 border"
                      style={{
                        background: selected ? 'var(--bg-elevated)' : 'transparent',
                        borderColor: selected ? 'var(--accent)' : 'transparent',
                      }}
                      onClick={() => void openCard(c)}
                    >
                      <div className="flex items-center gap-1.5">
                        <span
                          className="text-[10px] px-1.5 py-0.5 rounded shrink-0"
                          style={{ background: 'var(--bg-subtle)', color: 'var(--accent)' }}
                        >
                          {TYPE_LABELS[c.type] ?? c.type}
                        </span>
                        <span className="font-medium truncate">{c.title}</span>
                      </div>
                      <div className="truncate mt-0.5" style={{ color: 'var(--text-dim)' }}>
                        {c.summary}
                      </div>
                    </button>
                  )
                })}
              </div>
            </div>

            {/* 右栏:创建表单 或 渲染预览 */}
            <div className="flex-1 min-w-0 flex flex-col">
              {creating ? (
                <div className="p-4 space-y-3 overflow-y-auto">
                  <div className="space-y-1.5">
                    <label className="text-xs" style={{ color: 'var(--text-dim)' }}>标题 *</label>
                    <input
                      className="w-full text-sm rounded-md border px-3 py-2 outline-none"
                      style={inputStyle}
                      value={title}
                      onChange={(e) => setTitle(e.target.value)}
                    />
                  </div>
                  <div className="space-y-1.5">
                    <label className="text-xs" style={{ color: 'var(--text-dim)' }}>类型</label>
                    <select
                      className="w-full text-sm rounded-md border px-3 py-2 outline-none"
                      style={inputStyle}
                      value={type}
                      onChange={(e) => setType(e.target.value)}
                    >
                      {Object.entries(TYPE_LABELS).map(([k, v]) => (
                        <option key={k} value={k}>{v}</option>
                      ))}
                    </select>
                  </div>
                  <div className="space-y-1.5">
                    <label className="text-xs" style={{ color: 'var(--text-dim)' }}>摘要 *</label>
                    <input
                      className="w-full text-sm rounded-md border px-3 py-2 outline-none"
                      style={inputStyle}
                      value={summary}
                      onChange={(e) => setSummary(e.target.value)}
                    />
                  </div>
                  <div className="space-y-1.5">
                    <label className="text-xs" style={{ color: 'var(--text-dim)' }}>内容(支持 markdown)</label>
                    <textarea
                      className="w-full text-sm rounded-md border px-3 py-2 outline-none resize-none font-mono"
                      style={inputStyle}
                      rows={10}
                      value={content}
                      onChange={(e) => setContent(e.target.value)}
                    />
                  </div>
                  <div className="flex justify-end gap-2">
                    <button
                      className="text-sm px-4 py-1.5 rounded-md border"
                      style={{ borderColor: 'var(--border)' }}
                      onClick={() => setCreating(false)}
                    >
                      取消
                    </button>
                    <button
                      className="text-sm px-4 py-1.5 rounded-md"
                      style={{
                        background: 'var(--accent)',
                        color: '#fff',
                        opacity: title.trim().length > 0 && summary.trim().length > 0 ? 1 : 0.4,
                      }}
                      disabled={title.trim().length === 0 || summary.trim().length === 0}
                      onClick={() => void submit()}
                    >
                      创建卡片
                    </button>
                  </div>
                </div>
              ) : previewLoading ? (
                <p className="text-xs p-4" style={{ color: 'var(--text-dim)' }}>渲染中…</p>
              ) : preview !== null ? (
                <div className="flex flex-col min-h-0">
                  <div className="px-4 py-2 text-xs border-b shrink-0" style={{ borderColor: 'var(--border)', color: 'var(--text-dim)' }}>
                    {preview.title}
                  </div>
                  <iframe className="flex-1 bg-white" sandbox="" srcDoc={preview.html} title={preview.title} />
                </div>
              ) : (
                <div className="flex-1 flex items-center justify-center">
                  <p className="text-xs" style={{ color: 'var(--text-dim)' }}>
                    从左侧选择一张卡片查看渲染;或新建一张。
                  </p>
                </div>
              )}
            </div>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}