dsh-nextsteps 1.2.0 → 1.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # dsh-nextsteps
2
2
 
3
- [![npm](https://img.shields.io/npm/v/dsh-nextsteps)](https://www.npmjs.com/package/dsh-nextsteps)
4
- [![license](https://img.shields.io/github/license/klaus-2/dsh-nextsteps)](LICENSE)
5
- [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-e5484d)](https://github.com/deepseek-ai/deepseek-harness)
3
+ English · [Português (Brasil)](README.pt-BR.md) · [简体中文](README.zh-CN.md)
4
+
5
+ [![npm](https://img.shields.io/npm/v/dsh-nextsteps)](https://www.npmjs.com/package/dsh-nextsteps) [![license](https://img.shields.io/github/license/klaus-2/dsh-nextsteps)](LICENSE) [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-e5484d)](https://github.com/deepseek-ai/deepseek-harness)
6
6
 
7
7
  **Every finished agent turn ends with ready-to-click next steps.**
8
8
 
@@ -12,21 +12,24 @@
12
12
  dsh plugin --profile web add dsh-nextsteps
13
13
  ```
14
14
 
15
- ## How it works
15
+ ## Product tour
16
+
17
+ ![Nextsteps demo loop](docs/assets/demo.svg)
16
18
 
17
- 1. The plugin registers a native `suggest_followups` agent tool with a strict JSON Schema.
18
- 2. At the end of its turn, the model calls it with about three followup prompts.
19
- 3. Each completed turn renders its own suggestion cards — attributed to that turn, never repeated across messages.
20
- 4. Click a card: the prompt is sent verbatim as your next queued message and the card flips to a sent state.
19
+ 1. Ask anything. The model works, answers, and calls the native `suggest_followups` tool right before its final answer.
20
+ 2. The completed turn renders its own suggestion cards — attributed to that turn, never repeated across messages.
21
+ 3. Click a card: the prompt is sent verbatim as your next queued message and the card flips to a sent state.
22
+ 4. Everything survives restarts: cards, their turn attribution, and which ones you already clicked.
23
+ 5. Open the dsh settings → **Nextsteps** to pick the generation language (15 languages or Auto), how many suggestions you get per turn (1–6), and whether the small header shows.
21
24
 
22
25
  ## Features
23
26
 
24
- - Per-turn attribution — suggestions stay attached to the turn that produced them, across reloads.
27
+ - Per-turn attribution — suggestions stay attached to the turn that produced them, across reloads and app restarts.
25
28
  - One-click send through the session's own conversation controller, with a composer fallback.
26
- - Clicked cards persist (content-addressed keys) and stay marked after reloads.
29
+ - Persistent captures: clicked cards stay marked after restarts (content-addressed keys, turn backfilled from history).
27
30
  - Model-supplied labels, with automatic truncation when absent; strict payload validation with corrective model feedback.
28
31
  - Settings page inside the dsh settings — language, followups per turn, header visibility, updates.
29
- - Followups generated in the selected language (Auto follows the conversation).
32
+ - Followups generated in the selected language — 15 languages or Auto, which follows the conversation.
30
33
  - In-app updater — version check, one-click install, release notes, restart prompt.
31
34
  - UI in English, Português (Brasil) and 简体中文.
32
35
 
@@ -0,0 +1,57 @@
1
+ # dsh-nextsteps
2
+
3
+ [English](README.md) · Português (Brasil) · [简体中文](README.zh-CN.md)
4
+
5
+ [![npm](https://img.shields.io/npm/v/dsh-nextsteps)](https://www.npmjs.com/package/dsh-nextsteps) [![license](https://img.shields.io/github/license/klaus-2/dsh-nextsteps)](LICENSE) [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-e5484d)](https://github.com/deepseek-ai/deepseek-harness)
6
+
7
+ **Cada turno concluído do agente termina com próximos passos prontos para clicar.**
8
+
9
+ `dsh-nextsteps` é um plugin do [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) que faz o modelo sugerir cerca de três prompts de followup ao final de cada turno, renderiza-os como cards clicáveis sob o turno concluído e envia o prompt clicado como sua próxima mensagem — um clique, zero digitação.
10
+
11
+ ```
12
+ dsh plugin --profile web add dsh-nextsteps
13
+ ```
14
+
15
+ ## Tour do produto
16
+
17
+ ![Demo do Nextsteps](docs/assets/demo.svg)
18
+
19
+ 1. Pergunte qualquer coisa. O modelo trabalha, responde e chama a tool nativa `suggest_followups` logo antes da resposta final.
20
+ 2. O turno concluído renderiza seus próprios cards de sugestão — atribuídos àquele turno, nunca repetidos entre mensagens.
21
+ 3. Clique num card: o prompt é enviado literalmente como sua próxima mensagem na fila e o card muda para o estado de enviado.
22
+ 4. Tudo sobrevive a restarts: os cards, a atribuição de turno e os que você já clicou.
23
+ 5. Abra as configurações do dsh → **Nextsteps** para escolher o idioma de geração (15 idiomas ou Automático), quantas sugestões por turno (1–6) e se o pequeno cabeçalho aparece.
24
+
25
+ ## Funcionalidades
26
+
27
+ - Atribuição por turno — as sugestões ficam ligadas ao turno que as gerou, através de reloads e restarts do app.
28
+ - Envio com um clique pelo próprio controlador de conversa da sessão, com fallback pelo composer.
29
+ - Capturas persistentes: cards clicados continuam marcados após restarts (chaves endereçadas por conteúdo, turno retro-preenchido do histórico).
30
+ - Rótulos fornecidos pelo modelo, com truncamento automático quando ausentes; validação estrita do payload com feedback corretivo ao modelo.
31
+ - Página de configurações dentro das configurações do dsh — idioma, followups por turno, visibilidade do cabeçalho, atualizações.
32
+ - Followups gerados no idioma selecionado — 15 idiomas ou Automático, que segue a conversa.
33
+ - Atualizador in-app — verificação de versão, instalação com um clique, notas de release, pedido de reinício.
34
+ - Interface em English, Português (Brasil) e 简体中文.
35
+
36
+ ## Configurações
37
+
38
+ Abra as configurações do dsh e encontre a seção **Nextsteps**:
39
+
40
+ | Configuração | O que faz |
41
+ | --- | --- |
42
+ | Idioma | O idioma em que o modelo escreve os followups. `Automático` segue a conversa. |
43
+ | Followups por turno | Quantas sugestões o modelo produz (1–6). |
44
+ | Mostrar cabeçalho | O pequeno rótulo acima dos cards. |
45
+
46
+ ## Atualizações
47
+
48
+ A mesma página de configurações verifica novos releases no GitHub. Quando uma versão mais nova é publicada no npm, **Atualizar agora** instala pelo gerenciador de plugins do dsh e pede reinício. As atualizações instalam somente o pacote npm `dsh-nextsteps` — cópias renomeadas ou de desenvolvimento são detectadas e deixadas em paz.
49
+
50
+ ## Requisitos
51
+
52
+ - DeepSeek Harness 0.2.x (desktop, perfil web).
53
+ - Node.js 20+.
54
+
55
+ ## Licença
56
+
57
+ [MIT](LICENSE)
@@ -0,0 +1,57 @@
1
+ # dsh-nextsteps
2
+
3
+ [English](README.md) · [Português (Brasil)](README.pt-BR.md) · 简体中文
4
+
5
+ [![npm](https://img.shields.io/npm/v/dsh-nextsteps)](https://www.npmjs.com/package/dsh-nextsteps) [![license](https://img.shields.io/github/license/klaus-2/dsh-nextsteps)](LICENSE) [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-e5484d)](https://github.com/deepseek-ai/deepseek-harness)
6
+
7
+ **每个智能体回合的结尾,都是可以一键继续的下一步。**
8
+
9
+ `dsh-nextsteps` 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的插件:模型在每个回合结束时建议大约三个后续提示,以可点击的卡片渲染在已完成的回合下方;点击后该提示即原样作为你的下一条消息发送——一次点击,无需输入。
10
+
11
+ ```
12
+ dsh plugin --profile web add dsh-nextsteps
13
+ ```
14
+
15
+ ## 产品导览
16
+
17
+ ![Nextsteps 演示](docs/assets/demo.svg)
18
+
19
+ 1. 随意提问。模型完成工作、给出回答,并在最终回答之前调用原生的 `suggest_followups` 工具。
20
+ 2. 已完成的回合渲染出属于自己的建议卡片——归属于该回合,不会跨消息重复。
21
+ 3. 点击卡片:提示原样作为你的下一条排队消息发送,卡片翻转为已发送状态。
22
+ 4. 一切在重启后依然保留:卡片、回合归属、以及你已经点击过的那些。
23
+ 5. 打开 dsh 设置 → **Nextsteps**,选择生成语言(15 种语言或"自动")、每回合的建议数量(1–6)、以及是否显示小标题。
24
+
25
+ ## 功能
26
+
27
+ - 按回合归属——建议始终附着在产生它的回合上,跨刷新与应用重启。
28
+ - 通过会话自身的对话控制器一键发送,并有 composer 兜底。
29
+ - 持久化捕获:已点击的卡片在重启后保持标记(内容寻址键,回合由历史回填)。
30
+ - 模型提供的标签,缺失时自动截断;严格的负载校验,并向模型反馈纠错。
31
+ - dsh 设置内置配置页——语言、每回合建议数、标题可见性、更新。
32
+ - 以所选语言生成后续提示——15 种语言或"自动"(跟随对话语言)。
33
+ - 应用内更新器——版本检查、一键安装、发布说明、重启提示。
34
+ - 界面支持 English、Português (Brasil)、简体中文。
35
+
36
+ ## 设置
37
+
38
+ 打开 dsh 设置,找到 **Nextsteps** 部分:
39
+
40
+ | 设置 | 作用 |
41
+ | --- | --- |
42
+ | 语言 | 模型撰写后续提示使用的语言。`自动` 跟随对话语言。 |
43
+ | 每回合提示数 | 模型生成的建议数量(1–6)。 |
44
+ | 显示标题 | 卡片上方的小标题。 |
45
+
46
+ ## 更新
47
+
48
+ 同一个设置页会检查 GitHub 上的新版本。当 npm 上发布了更新的版本时,**立即更新** 会通过 dsh 插件管理器安装并提示重启。更新只会安装 npm 包 `dsh-nextsteps`——重命名或开发副本会被识别并保持不动。
49
+
50
+ ## 环境要求
51
+
52
+ - DeepSeek Harness 0.2.x(桌面版,web profile)。
53
+ - Node.js 20+。
54
+
55
+ ## 许可证
56
+
57
+ [MIT](LICENSE)
package/client.js CHANGED
@@ -343,11 +343,27 @@ window.__ModuleLoader__.load({
343
343
  { value: "vi", label: "Tiếng Việt" }
344
344
  ];
345
345
  const NPM_NAME = 'dsh-nextsteps';
346
+ const MODELS_ROUTE = '/api/dsh-nextsteps.models';
347
+ const GENERATE_ROUTE = '/api/dsh-nextsteps.generate';
348
+ let modelCatalogCache = undefined;
349
+
350
+ async function loadModelCatalog(force) {
351
+ if (modelCatalogCache === undefined || force === true) {
352
+ try {
353
+ const response = await fetch(MODELS_ROUTE, { cache: 'no-store' });
354
+ const data = await response.json();
355
+ if (data !== null && typeof data === 'object' && data.ok === true) modelCatalogCache = data;
356
+ } catch (error) {
357
+ console.warn('dsh-nextsteps: model catalog unavailable:', error);
358
+ }
359
+ }
360
+ return modelCatalogCache;
361
+ }
346
362
  const ICON_DATAURI = 'data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20256%20256%22%20role%3D%22img%22%20aria-label%3D%22Nextsteps%20icon%22%3E%0A%20%20%3Crect%20width%3D%22256%22%20height%3D%22256%22%20rx%3D%2256%22%20fill%3D%22%230d0d10%22%2F%3E%0A%20%20%3Crect%20x%3D%2252%22%20y%3D%2264%22%20width%3D%22152%22%20height%3D%2244%22%20rx%3D%2210%22%20fill%3D%22%231f242b%22%20stroke%3D%22%234b5563%22%20stroke-width%3D%223%22%2F%3E%0A%20%20%3Crect%20x%3D%2252%22%20y%3D%22124%22%20width%3D%22152%22%20height%3D%2244%22%20rx%3D%2210%22%20fill%3D%22%231f242b%22%20stroke%3D%22%234b5563%22%20stroke-width%3D%223%22%2F%3E%0A%20%20%3Crect%20x%3D%2252%22%20y%3D%22184%22%20width%3D%22152%22%20height%3D%2244%22%20rx%3D%2210%22%20fill%3D%22%23e5484d%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M84%20206h72M136%20194l14%2012-14%2012%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%228%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20fill%3D%22none%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M76%2086h96M76%20146h96%22%20stroke%3D%22%236b7280%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3C%2Fsvg%3E%0A';
347
363
  const SETTINGS_ROUTE = '/api/dsh-nextsteps.settings';
348
364
  const UPDATE_ROUTE = '/api/dsh-nextsteps.update';
349
365
  const DICTIONARIES = {
350
- en: {"header":"Suggested followups","showEarlier":"Show earlier suggestions","hideEarlier":"Hide earlier suggestions","srFollowupSent":"Followup sent","sendFailed":"Could not send: ","intro":"Suggested next-step prompts under every finished agent turn — one click sends the followup.","setLanguage":"Language","setLanguageTip":"Followups are generated in this language. Auto follows the conversation.","optAuto":"Auto (follow the conversation)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setMax":"Followups per turn","setMaxTip":"How many suggested prompts the model should produce (1-6).","setHeader":"Show header","setHeaderTip":"The small “Suggested followups” label above the cards.","updates":"Updates","current":"Version","checking":"Checking…","upToDate":"up to date","available":"{v} is available","manualCopy":"Development or renamed copy — update it manually.","installing":"Installing {v}…","restartMsg":"Version {v} was installed. Restart the dsh to use it.","noReleases":"No releases published yet","checkFailed":"Update check failed ({e})","updateNow":"Update now","check":"Check for updates","openRelease":"Open release page","releaseNotes":"Release notes","repo":"GitHub repository","issues":"Report an issue"},
366
+ en: {"header":"Suggested followups","showEarlier":"Show earlier suggestions","hideEarlier":"Hide earlier suggestions","srFollowupSent":"Followup sent","sendFailed":"Could not send: ","intro":"Suggested next-step prompts under every finished agent turn — one click sends the followup.","setLanguage":"Language","setLanguageTip":"Followups are generated in this language. Auto follows the conversation.","optAuto":"Auto (follow the conversation)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文",setModel:"Model","setModelTip":"The model that generates the suggestions. Auto keeps them inside the main turn.","optModelAuto":"Auto (same model as the chat)",setModel:"Modelo","setModelTip":"O modelo que gera as sugestões. Automático as mantém no turno principal.","optModelAuto":"Automático (mesmo modelo da conversa)",setModel:"模型","setModelTip":"生成建议的模型。自动会跟随对话所用的模型。","optModelAuto":"自动(与对话相同的模型)","setMax":"Followups per turn","setMaxTip":"How many suggested prompts the model should produce (1-6).","setHeader":"Show header","setHeaderTip":"The small “Suggested followups” label above the cards.","updates":"Updates","current":"Version","checking":"Checking…","upToDate":"up to date","available":"{v} is available","manualCopy":"Development or renamed copy — update it manually.","installing":"Installing {v}…","restartMsg":"Version {v} was installed. Restart the dsh to use it.","noReleases":"No releases published yet","checkFailed":"Update check failed ({e})","updateNow":"Update now","check":"Check for updates","openRelease":"Open release page","releaseNotes":"Release notes","repo":"GitHub repository","issues":"Report an issue"},
351
367
  'pt-BR': {"header":"Sugestões de acompanhamento","showEarlier":"Mostrar sugestões anteriores","hideEarlier":"Ocultar sugestões anteriores","srFollowupSent":"Follow-up enviado","sendFailed":"Não consegui enviar: ","intro":"Sugestões de próximos passos sob cada turno concluído do agente — um clique envia o followup.","setLanguage":"Idioma","setLanguageTip":"Os followups são gerados neste idioma. Automático segue a conversa.","optAuto":"Automático (segue a conversa)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setMax":"Followups por turno","setMaxTip":"Quantas sugestões o modelo deve produzir (1-6).","setHeader":"Mostrar cabeçalho","setHeaderTip":"O rótulo pequeno “Sugestões de acompanhamento” acima dos cartões.","updates":"Atualizações","current":"Versão","checking":"Verificando…","upToDate":"em dia","available":"{v} disponível","manualCopy":"Cópia de desenvolvimento ou renomeada — atualize manualmente.","installing":"Instalando {v}…","restartMsg":"Versão {v} instalada. Reinicie o dsh para usá-la.","noReleases":"Nenhum release publicado ainda","checkFailed":"Falha ao verificar atualizações ({e})","updateNow":"Atualizar agora","check":"Verificar atualizações","openRelease":"Abrir página do release","releaseNotes":"Notas do release","repo":"Repositório no GitHub","issues":"Reportar um problema"},
352
368
  'zh-CN': {"header":"建议的后续操作","showEarlier":"显示较早的建议","hideEarlier":"隐藏较早的建议","srFollowupSent":"后续操作已发送","sendFailed":"发送失败:","intro":"在每个智能体回合完成后的下方推荐下一步提示——单击即可发送。","setLanguage":"语言","setLanguageTip":"后续操作提示将以此语言生成。“自动”会跟随对话语言。","optAuto":"自动(跟随对话)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setMax":"每回合提示数","setMaxTip":"模型应生成的建议提示数量(1-6)。","setHeader":"显示标题","setHeaderTip":"卡片上方的小标题“建议的后续操作”。","updates":"更新","current":"版本","checking":"检查中…","upToDate":"已是最新","available":"{v} 可用","manualCopy":"开发或重命名的副本——请手动更新。","installing":"正在安装 {v}…","restartMsg":"版本 {v} 已安装。请重启 dsh 以使用。","noReleases":"尚未发布任何版本","checkFailed":"检查更新失败({e})","updateNow":"立即更新","check":"检查更新","openRelease":"打开发布页","releaseNotes":"发布说明","repo":"GitHub 仓库","issues":"反馈问题"}
353
369
  };
@@ -456,6 +472,7 @@ window.__ModuleLoader__.load({
456
472
  return function SuggestFollowupsTail(props) {
457
473
  const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '';
458
474
  const turn = turnIdOf(props.turn);
475
+ const afterSeq = props.turn !== null && typeof props.turn === 'object' && Number.isSafeInteger(props.turn.end?.seq) ? props.turn.end.seq : undefined;
459
476
  const [, refresh] = React.useReducer((count) => count + 1, 0);
460
477
  React.useEffect(() => {
461
478
  captureListeners.add(refresh);
@@ -463,9 +480,26 @@ window.__ModuleLoader__.load({
463
480
  captureListeners.delete(refresh);
464
481
  };
465
482
  }, []);
466
-
467
-
468
-
483
+ const generatedRef = React.useRef(null);
484
+ React.useEffect(() => {
485
+ if (pluginSettings.model === '' || sessionId === '' || turn === undefined || afterSeq === undefined) return undefined;
486
+ const key = sessionId + '|' + turn;
487
+ if (generatedRef.current === key) return undefined;
488
+ if (captureForTurn(sessionId, turn) !== null) return undefined;
489
+ generatedRef.current = key;
490
+ let alive = true;
491
+ (async () => {
492
+ try {
493
+ const response = await fetch(GENERATE_ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, afterSeq }) });
494
+ const data = await response.json();
495
+ if (!alive || !response.ok || data === null || typeof data !== 'object' || !Array.isArray(data.followups) || data.followups.length === 0) return;
496
+ rememberCapture(sessionId, 'generated-' + turn, turn, validateFollowups(data.followups));
497
+ } catch (error) {
498
+ console.warn('dsh-nextsteps: generation failed:', error);
499
+ }
500
+ })();
501
+ return () => { alive = false; };
502
+ }, [sessionId, turn, afterSeq]);
469
503
  let entry = turn !== undefined ? captureForTurn(sessionId, turn) : newestCapture(sessionId);
470
504
  if (entry === null) {
471
505
  const unclaimed = unclaimedCaptures(sessionId, turn);
@@ -490,13 +524,19 @@ window.__ModuleLoader__.load({
490
524
  refreshPluginSettings().then((loaded) => setSettings({ ...loaded }));
491
525
  check();
492
526
  }, []);
527
+ const [models, setModels] = React.useState(() => modelCatalogCache);
528
+ React.useEffect(() => {
529
+ let alive = true;
530
+ loadModelCatalog(true).then((catalog) => { if (alive) setModels(catalog); });
531
+ return () => { alive = false; };
532
+ }, []);
493
533
  const save = async (patch) => {
494
534
  const merged = { ...settings, ...patch };
495
535
  setSettings(merged);
496
536
  try {
497
537
  const response = await fetch(SETTINGS_ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) });
498
538
  const data = await response.json();
499
- if (data !== null && typeof data === 'object' && data.ok === true && data.settings !== null && typeof data.settings === 'object') setSettings(data.settings);
539
+ if (data !== null && typeof data === 'object' && data.ok === true && data.settings !== null && typeof data.settings === 'object') { pluginSettings = data.settings; setSettings(data.settings); }
500
540
  } catch (error) {
501
541
  console.warn('dsh-nextsteps: settings save failed:', error);
502
542
  }
@@ -564,6 +604,10 @@ window.__ModuleLoader__.load({
564
604
  row(t('setLanguage'), t('setLanguageTip'), h('select', { value: settings.lang, onChange: (event) => save({ lang: event.target.value }), style: controlStyle, 'aria-label': t('setLanguage') },
565
605
  h('option', { value: 'auto' }, t('optAuto')),
566
606
  ...LANGUAGE_OPTIONS.map((option) => h('option', { key: option.value, value: option.value }, option.label))), 'lang'),
607
+ row(t('setModel'), t('setModelTip'), (models === undefined || (models.groups ?? []).length === 0) ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, t('checking')) : h('select', { value: settings.model, onChange: (event) => save({ model: event.target.value }), style: controlStyle, 'aria-label': t('setModel') },
608
+ h('option', { value: '' }, t('optModelAuto')),
609
+ ...(models.groups ?? []).map((group) => h('optgroup', { key: group.id, label: group.name },
610
+ group.models.map((entry) => h('option', { key: group.id + '/' + entry.id, value: group.id + '/' + entry.id }, entry.name === entry.id ? entry.name : entry.name + ' (' + entry.id + ')'))))), 'model'),
567
611
  row(t('setMax'), t('setMaxTip'), h('input', { type: 'number', min: 1, max: 6, value: settings.maxFollowups, onChange: (event) => save({ maxFollowups: Number(event.target.value) }), style: { ...controlStyle, width: 64 }, 'aria-label': t('setMax') }), 'max'),
568
612
  row(t('setHeader'), t('setHeaderTip'), h('input', { type: 'checkbox', checked: settings.showHeader, onChange: (event) => save({ showHeader: event.target.checked }), 'aria-label': t('setHeader') }), 'header'),
569
613
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24, padding: '14px 0', borderBottom: '.5px solid var(--dsw-alias-border-l1)', flexWrap: 'wrap' } },
package/index.js CHANGED
@@ -6,11 +6,13 @@ import path from 'node:path';
6
6
 
7
7
  const TOOL_NAME = 'suggest_followups';
8
8
  const name = 'dsh-nextsteps';
9
- const inject = ['tools', 'connection'];
9
+ const inject = ['tools', 'connection', 'sessionQuery'];
10
10
  const NPM_NAME = 'dsh-nextsteps';
11
11
  const RELEASES_URL = 'https://api.github.com/repos/klaus-2/dsh-nextsteps/releases/latest';
12
12
  const SETTINGS_FILE = path.join(homedir(), '.dsh', 'dsh-nextsteps', 'settings.json');
13
13
  const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
14
+ const GENERATE_TIMEOUT_MS = 30000;
15
+ const GENERATE_MAX_TOKENS = 500;
14
16
 
15
17
  const LANGUAGE_NAMES = {
16
18
  auto: "the same language as the conversation",
@@ -31,16 +33,25 @@ const LANGUAGE_NAMES = {
31
33
  'vi': "Vietnamese"
32
34
  };
33
35
 
36
+ function modelKeyOf(stored) {
37
+ if (stored === null || typeof stored !== 'object') return '';
38
+ if (typeof stored.model === 'string') return stored.model.includes('/') ? stored.model : '';
39
+ const provider = typeof stored.provider === 'string' ? stored.provider : '';
40
+ const model = typeof stored.model === 'string' ? stored.model : '';
41
+ return provider !== '' && model !== '' ? `${provider}/${model}` : '';
42
+ }
43
+
34
44
  function settingsLoad() {
35
45
  try {
36
46
  const stored = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')) ?? {};
37
47
  return {
38
48
  lang: ["auto","en","pt-BR","zh-CN","es","fr","de","it","ja","ko","ru","ar","hi","id","tr","vi"].includes(stored.lang) ? stored.lang : 'auto',
39
49
  maxFollowups: Number.isInteger(stored.maxFollowups) && stored.maxFollowups >= 1 && stored.maxFollowups <= 6 ? stored.maxFollowups : 3,
40
- showHeader: stored.showHeader !== false
50
+ showHeader: stored.showHeader !== false,
51
+ model: typeof stored.model === 'string' ? (stored.model.includes('/') ? stored.model : '') : modelKeyOf(stored)
41
52
  };
42
53
  } catch {
43
- return { lang: 'auto', maxFollowups: 3, showHeader: true };
54
+ return { lang: 'auto', maxFollowups: 3, showHeader: true, model: '' };
44
55
  }
45
56
  }
46
57
 
@@ -156,6 +167,114 @@ function parseVersion(value) {
156
167
  return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
157
168
  }
158
169
 
170
+ const get = (ctx, serviceName) => { try { return ctx.get?.(serviceName); } catch { return undefined; } };
171
+
172
+ async function modelCatalog(ctx) {
173
+ const llm = get(ctx, 'llm');
174
+ const fallback = get(ctx, 'agentDefaultModel')?.currentSelection?.();
175
+ const out = {
176
+ default: typeof fallback?.provider === 'string' && typeof fallback?.model === 'string' && fallback.provider !== '' && fallback.model !== ''
177
+ ? { provider: fallback.provider, model: fallback.model }
178
+ : undefined,
179
+ groups: []
180
+ };
181
+ if (llm?.listProviders === undefined) return out;
182
+ for (const provider of llm.listProviders()) {
183
+ try {
184
+ const models = (await llm.listModels(provider.id)).map((entry) => ({ id: entry.id, name: entry.name }));
185
+ if (models.length > 0) out.groups.push({ id: provider.id, name: provider.name ?? provider.id, models });
186
+ } catch {
187
+ continue;
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ function turnTranscript(events, afterSeq) {
194
+ const lines = [];
195
+ for (const entry of events) {
196
+ const event = entry?.event ?? entry;
197
+ if (event === null || typeof event !== 'object') continue;
198
+ if (Number.isSafeInteger(afterSeq) && Number.isSafeInteger(event.seq) && event.seq > afterSeq) break;
199
+ if (event.type === 'user/message' && typeof event.data?.text === 'string') lines.push(`User: ${event.data.text.slice(0, 2000)}`);
200
+ else if (event.type === 'agent/message' && typeof event.data?.text === 'string') lines.push(`Assistant: ${event.data.text.slice(0, 2000)}`);
201
+ else if (event.type === 'tool/call' && typeof event.data?.name === 'string') lines.push(`Tool call: ${event.data.name}`);
202
+ }
203
+ return lines.join('\n\n');
204
+ }
205
+
206
+ function generateSystem(settings) {
207
+ const language = LANGUAGE_NAMES[settings.lang] ?? LANGUAGE_NAMES.auto;
208
+ return [
209
+ 'You suggest followup prompts the user of a coding-agent chat may want next. You answer with JSON only — no prose, no code fence.',
210
+ `Output shape: {"followups":[{"prompt":"...","label":"..."}]} with exactly ${settings.maxFollowups} items.`,
211
+ 'Each prompt is one short sentence in the user\'s first-person voice, goal-oriented, standing alone without the transcript; each label is 2-5 words.',
212
+ `Write every prompt and label in ${language}.`,
213
+ 'Good suggestions: alternatives to the latest change, related features, cleanup, tests, verification steps. If the transcript shows remaining plan steps, one suggestion may continue the next step.'
214
+ ].join('\n');
215
+ }
216
+
217
+ function followupsFromModelText(text) {
218
+ if (typeof text !== 'string' || text === '') return undefined;
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim());
222
+ } catch {
223
+ const start = text.indexOf('{');
224
+ const end = text.lastIndexOf('}');
225
+ if (start === -1 || end <= start) return undefined;
226
+ try { parsed = JSON.parse(text.slice(start, end + 1)); } catch { return undefined; }
227
+ }
228
+ const followups = coerceFollowups(parsed);
229
+ if (followups === undefined) return undefined;
230
+ return followups.slice(0, 6).map((entry) => ({ prompt: String(entry.prompt).slice(0, 200), label: entry.label === null ? null : String(entry.label).slice(0, 40) }));
231
+ }
232
+
233
+ async function generateForTurn(ctx, settings, sessionId, afterSeq, signal) {
234
+ const sessionQuery = get(ctx, 'sessionQuery');
235
+ if (sessionQuery?.observeSession === undefined) throw new Error('the sessionQuery service is unavailable');
236
+ const observation = await sessionQuery.observeSession(sessionId, { signal, projectionMode: 'none' });
237
+ let transcript;
238
+ try {
239
+ const events = observation.events ?? [];
240
+ const last = events.at(-1)?.event ?? events.at(-1);
241
+ if (last?.type !== 'turn/end' || last.data?.reason?.kind !== 'completed' || last.seq !== afterSeq) {
242
+ throw Object.assign(new Error('the chat moved on'), { status: 409 });
243
+ }
244
+ transcript = turnTranscript(events, afterSeq);
245
+ } finally {
246
+ observation[Symbol.dispose]?.();
247
+ }
248
+ if (transcript.trim() === '') return { followups: [] };
249
+ const catalog = await modelCatalog(ctx);
250
+ const route = settings.model !== ''
251
+ ? (() => {
252
+ const slash = settings.model.indexOf('/');
253
+ const provider = settings.model.slice(0, slash);
254
+ const model = settings.model.slice(slash + 1);
255
+ if (catalog.groups.some((group) => group.id === provider && group.models.some((entry) => entry.id === model))) return { provider, model };
256
+ throw Object.assign(new Error('the selected model is no longer listed'), { status: 409 });
257
+ })()
258
+ : catalog.default;
259
+ if (route === undefined) throw Object.assign(new Error('no model is selected'), { status: 409 });
260
+ const llm = get(ctx, 'llm');
261
+ if (llm?.prepareCall === undefined) throw new Error('the llm service is unavailable');
262
+ const timeout = AbortSignal.timeout(GENERATE_TIMEOUT_MS);
263
+ const callSignal = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
264
+ const call = await llm.prepareCall(route, callSignal, (controls, model) => {
265
+ const cap = Number.isSafeInteger(model?.defaultMaxTokens) && model.defaultMaxTokens > 0 ? Math.min(GENERATE_MAX_TOKENS, model.defaultMaxTokens) : GENERATE_MAX_TOKENS;
266
+ return { ...controls, maxTokens: cap };
267
+ });
268
+ let text = '';
269
+ let finish;
270
+ for await (const chunk of call.stream({ ...call.config, system: generateSystem(settings), signal: callSignal, messages: [{ role: 'user', content: [{ type: 'text', text: `<transcript>\n${transcript}\n</transcript>\n\nSuggest the followup prompts.` }] }] })) {
271
+ if (chunk.type === 'text-delta') text += chunk.text;
272
+ else if (chunk.type === 'finish') finish = chunk.reason;
273
+ }
274
+ if (finish?.kind === 'error' || finish?.kind === 'aborted') throw new Error(finish.failure?.message || `the model call ended: ${finish.kind}`);
275
+ return { followups: followupsFromModelText(text) ?? [], model: route.model };
276
+ }
277
+
159
278
  function newerVersion(latest, current) {
160
279
  for (let index = 0; index < 3; index += 1) {
161
280
  if ((latest[index] ?? 0) !== (current[index] ?? 0)) return (latest[index] ?? 0) > (current[index] ?? 0);
@@ -181,20 +300,22 @@ async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
181
300
 
182
301
  async function apply(ctx) {
183
302
  let settings = settingsLoad();
303
+ const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
304
+ const isExternal = (next) => typeof next.model === 'string' && next.model !== '';
184
305
  try {
185
- ctx.tools.register(toolSpec(settings));
306
+ if (!isExternal(settings)) ctx.tools.register(toolSpec(settings));
186
307
  } catch (error) {
187
308
  console.error('dsh-nextsteps: tool registration failed:', error);
188
309
  }
189
310
  const reapplyTool = (next) => {
190
311
  settings = next;
191
312
  try {
192
- ctx.tools.register(toolSpec(settings));
313
+ if (isExternal(next)) ctx.tools.unregister(TOOL_NAME);
314
+ else ctx.tools.register(toolSpec(settings));
193
315
  } catch (error) {
194
316
  console.error('dsh-nextsteps: tool re-registration failed:', error);
195
317
  }
196
318
  };
197
- const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
198
319
  ctx.connection?.fetch?.register({
199
320
  path: '/api/dsh-nextsteps.settings',
200
321
  methods: ['GET', 'POST'],
@@ -207,16 +328,50 @@ async function apply(ctx) {
207
328
  const next = {
208
329
  lang: ["auto","en","pt-BR","zh-CN","es","fr","de","it","ja","ko","ru","ar","hi","id","tr","vi"].includes(body?.lang) ? body.lang : current.lang,
209
330
  maxFollowups: Number.isInteger(body?.maxFollowups) && body.maxFollowups >= 1 && body.maxFollowups <= 6 ? body.maxFollowups : current.maxFollowups,
210
- showHeader: typeof body?.showHeader === 'boolean' ? body.showHeader : current.showHeader
331
+ showHeader: typeof body?.showHeader === 'boolean' ? body.showHeader : current.showHeader,
332
+ model: typeof body?.model === 'string' && body.model.includes('/') ? body.model : body?.model === '' || body?.model === undefined || body?.model === null ? '' : current.model
211
333
  };
212
334
  await settingsSave(next);
213
335
  reapplyTool(next);
214
336
  return json({ ok: true, settings: next });
215
337
  }
216
338
  });
339
+ ctx.connection?.fetch?.register({
340
+ path: '/api/dsh-nextsteps.models',
341
+ methods: ['GET'],
342
+ requestBody: 'buffered',
343
+ fetch: async (request) => {
344
+ try {
345
+ const catalog = await modelCatalog(ctx);
346
+ return json({ ok: true, ...catalog });
347
+ } catch (error) {
348
+ return json({ error: String(error?.message ?? error) }, 502);
349
+ }
350
+ }
351
+ });
352
+ ctx.connection?.fetch?.register({
353
+ path: '/api/dsh-nextsteps.generate',
354
+ methods: ['POST'],
355
+ requestBody: 'buffered',
356
+ fetch: async (request) => {
357
+ let body;
358
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
359
+ if (typeof body?.sessionId !== 'string' || body.sessionId === '' || !Number.isSafeInteger(body?.afterSeq) || body.afterSeq < 0) {
360
+ return json({ error: 'bad request' }, 400);
361
+ }
362
+ try {
363
+ const result = await generateForTurn(ctx, settings, body.sessionId, body.afterSeq, request.signal);
364
+ return json({ ok: true, rev: PLUGIN_REV, ...result });
365
+ } catch (error) {
366
+ const status = error?.status === 409 || error?.status === 400 ? error.status : 502;
367
+ return json({ error: String(error?.message ?? error) }, status);
368
+ }
369
+ }
370
+ });
217
371
  ctx.connection?.fetch?.register({
218
372
  path: '/api/dsh-nextsteps.update',
219
373
  methods: ['GET'],
374
+ requestBody: 'buffered',
220
375
  fetch: async (request) => {
221
376
  try {
222
377
  const info = await checkUpdate(fetch, PACKAGE, request.signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-nextsteps",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.js",
@@ -14,7 +14,9 @@
14
14
  "lib/locale",
15
15
  "docs/assets/icon.png",
16
16
  "README.md",
17
- "LICENSE"
17
+ "LICENSE",
18
+ "README.pt-BR.md",
19
+ "README.zh-CN.md"
18
20
  ],
19
21
  "dsh": {
20
22
  "bundle": {