@morehao/dsh-skills-console 0.5.5 → 0.5.6

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.en.md CHANGED
@@ -157,8 +157,10 @@ AI intro settings (in the plugin `config` of the profile / `cordis.patch.yml`):
157
157
  | `intro.enabled` | `true` | `false` hides the ✨ entry and the API returns 501 |
158
158
  | `intro.provider` / `intro.model` | empty | empty uses DSH's current default model; both must be set together |
159
159
  | `intro.maxInputBytes` | `24576` | cap on the `SKILL.md` sent to the model (8–48 KB); longer input is truncated and marked in the prompt |
160
- | `intro.maxOutputTokens` | `300` | output cap (96–1024) |
161
- | `intro.timeoutMs` | `30000` | per-generation timeout (5–60 s); timeouts return 504 |
160
+ | `intro.maxOutputTokens` | `4096` | output ceiling (96–8192). This is a runaway guard, not a length target; reasoning tokens count toward it, and the effective floor is raised per model |
161
+ | `intro.timeoutMs` | `60000` | per-generation timeout (5–60 s); timeouts return 504 |
162
+
163
+ The intro length is controlled by the 160-character prompt target. Do not treat `maxOutputTokens` as a length target; lowering it does not save cost, it only leaves a reasoning model no room for visible text.
162
164
 
163
165
  ## Development and publishing
164
166
 
package/README.md CHANGED
@@ -157,8 +157,10 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
157
157
  | `intro.enabled` | `true` | `false` 时界面隐藏 ✨ 入口,接口返回 501 |
158
158
  | `intro.provider` / `intro.model` | 空 | 留空用 DSH 当前默认模型;两个必须同时给 |
159
159
  | `intro.maxInputBytes` | `24576` | 发给模型的 `SKILL.md` 上限(8–48 KB),超出截断并在提示词里标注 |
160
- | `intro.maxOutputTokens` | `300` | 输出上限(96–1024) |
161
- | `intro.timeoutMs` | `30000` | 单次生成超时(5–60 秒),超时返回 504 |
160
+ | `intro.maxOutputTokens` | `4096` | 输出上限(96–8192)。这是防跑飞的预算,不是长度目标;推理模型的思考 token 也计入,实际会按模型自动抬高保底 |
161
+ | `intro.timeoutMs` | `60000` | 单次生成超时(5–60 秒),超时返回 504 |
162
+
163
+ 简介长度由提示词里的 160 字控制,不要把 `maxOutputTokens` 当成目标长度;调小它不会省钱,只会让推理模型没有输出正文的空间。
162
164
 
163
165
  ## 开发与发布
164
166
 
package/client.js CHANGED
@@ -244,6 +244,7 @@ var DICT = {
244
244
  "form.subpath": "子目录(可空)",
245
245
  "form.hint": "会立即下载并写入 ~/.agents/skills,并按「新安装时自动共享」的选择共享至对应 Agent。",
246
246
  "form.subHint": "只记录你关注这个仓库,不会安装任何技能。",
247
+ "form.submitFailed": "添加失败,请重试",
247
248
  "subscription.defaultRef": "默认分支",
248
249
  "confirm.deleteTitle": "删除技能",
249
250
  "confirm.deleteBody": "会从本机移除,断开本插件建立的其他 Agent 链接并清理记录;目录先备份到回收站(保留 7 天)。不会删除你的源仓库。",
@@ -356,6 +357,9 @@ var DICT = {
356
357
  "intro.error.timeout": "生成超时,请重试",
357
358
  "intro.error.auth": "模型提供方认证失败",
358
359
  "intro.error.empty": "模型没有返回内容",
360
+ "intro.error.truncated": "简介被输出上限截断,请重试",
361
+ "intro.error.reasoningOnly": "模型只输出了思考过程,没有输出正文;请提高输出上限或换非推理模型",
362
+ "intro.error.noText": "模型没有输出文字简介,请重试",
359
363
  "intro.error.busy": "正在生成,请稍候",
360
364
  "intro.error.source": "读不到远端的 SKILL.md(仓库私有、已删除或网络问题)",
361
365
  "intro.error.noSource": "订阅快照里没有这个技能,请先点「检查更新」",
@@ -525,6 +529,7 @@ var DICT = {
525
529
  "form.subpath": "Subdirectory (optional)",
526
530
  "form.hint": "Downloads into ~/.agents/skills and shares it with agents per the auto-share setting.",
527
531
  "form.subHint": "Only records the repository; installs nothing.",
532
+ "form.submitFailed": "Failed; please try again",
528
533
  "subscription.defaultRef": "default branch",
529
534
  "confirm.deleteTitle": "Delete skill",
530
535
  "confirm.deleteBody": "Removes it locally, disconnects the agent links this plugin created, and clears the record. A snapshot stays in trash for 7 days. Your source repo is untouched.",
@@ -638,6 +643,9 @@ var DICT = {
638
643
  "intro.error.timeout": "Generation timed out; try again",
639
644
  "intro.error.auth": "The model provider rejected the credentials",
640
645
  "intro.error.empty": "The model returned no content",
646
+ "intro.error.truncated": "The intro hit the output limit and was truncated; try again",
647
+ "intro.error.reasoningOnly": "The model only produced reasoning, not intro text; raise the output limit or use a non-reasoning model",
648
+ "intro.error.noText": "The model returned no text intro; try again",
641
649
  "intro.error.busy": "Already generating; please wait",
642
650
  "intro.error.source": "Could not read the remote SKILL.md (private repo, deleted, or network)",
643
651
  "intro.error.noSource": "This skill is not in the subscription snapshot; run “Check for updates” first",
@@ -1152,6 +1160,7 @@ var CSS = `
1152
1160
  /* 标题不参与收窄:窄面板里让身份胶囊先省略,标题本身不折行 */
1153
1161
  .dshsc-title { font-size: 17px; font-weight: 600; letter-spacing: -.01em; margin: 0; flex: none; white-space: nowrap; }
1154
1162
  .dshsc-sub { font-size: 12px; line-height: 1.55; color: var(--dsw-alias-label-secondary); margin: 0; max-width: 64ch; }
1163
+ .dshsc-form-error { font-size: 12px; line-height: 1.55; margin: 8px 0 0; max-width: 64ch; color: var(--dsw-alias-state-error-primary); }
1155
1164
  .dshsc-spacer { flex: 1; }
1156
1165
  .dshsc-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; margin-left: auto; }
1157
1166
  /* 标题行恒为一行:身份胶囊只在面板极窄(连标题行都放不下)时才省略,工具组不被挤走。
@@ -1984,26 +1993,38 @@ function useSkillActions({ t, rows, reload, run, setBusy, setToast, setModal })
1984
1993
  return apiCall("POST", "/probe", { source: String(source || "").trim() });
1985
1994
  }
1986
1995
  async function installSkills({ source, skills }) {
1987
- setModal(null);
1988
- await run("install", async () => {
1989
- const result = await apiCall("POST", "/install", { source: String(source || "").trim(), skills });
1990
- const warnings = result.warnings?.length || 0;
1991
- setToast({
1992
- kind: warnings ? "err" : "ok",
1993
- text: `${result.installed?.length || 0} ${t("skillCount")}${warnings ? ` · ${warnings} 条提示` : ""}`
1994
- });
1995
- });
1996
+ try {
1997
+ await run("install", async () => {
1998
+ const result = await apiCall("POST", "/install", { source: String(source || "").trim(), skills });
1999
+ const warnings = result.warnings?.length || 0;
2000
+ setToast({
2001
+ kind: warnings ? "err" : "ok",
2002
+ text: `${result.installed?.length || 0} ${t("skillCount")}${warnings ? ` · ${warnings} 条提示` : ""}`
2003
+ });
2004
+ return result;
2005
+ }, { toastError: false });
2006
+ setModal(null);
2007
+ return { ok: true };
2008
+ } catch (err) {
2009
+ return { ok: false, error: err?.message || String(err) };
2010
+ }
1996
2011
  }
1997
2012
  async function addSubscription(form) {
1998
- setModal(null);
1999
- await run("subscribe", async () => {
2000
- const result = await apiCall("POST", "/subscriptions", {
2001
- source: form.source.trim(),
2002
- ref: form.ref.trim() || void 0,
2003
- subpath: form.subpath.trim() || void 0
2004
- });
2005
- setToast({ kind: "ok", text: result.subscription?.source || form.source });
2006
- });
2013
+ try {
2014
+ const result = await run("subscribe", async () => {
2015
+ const res = await apiCall("POST", "/subscriptions", {
2016
+ source: form.source.trim(),
2017
+ ref: form.ref.trim() || void 0,
2018
+ subpath: form.subpath.trim() || void 0
2019
+ });
2020
+ setToast({ kind: "ok", text: res.subscription?.source || form.source });
2021
+ return res;
2022
+ }, { toastError: false });
2023
+ setModal(null);
2024
+ return { ok: true, result };
2025
+ } catch (err) {
2026
+ return { ok: false, error: err?.message || String(err) };
2027
+ }
2007
2028
  }
2008
2029
  async function refreshSubscription(row) {
2009
2030
  await run(`refresh:${row.id}`, async () => {
@@ -2070,14 +2091,14 @@ function useSkillsData() {
2070
2091
  return () => clearTimeout(timer);
2071
2092
  }, [toast]);
2072
2093
  const run = import_react2.default.useCallback(
2073
- async (label, task) => {
2094
+ async (label, task, { toastError = true } = {}) => {
2074
2095
  setBusy(label);
2075
2096
  try {
2076
2097
  const result = await task();
2077
2098
  await reload();
2078
2099
  return result;
2079
2100
  } catch (err) {
2080
- setToast({ kind: "err", text: err.message });
2101
+ if (toastError) setToast({ kind: "err", text: err.message });
2081
2102
  throw err;
2082
2103
  } finally {
2083
2104
  setBusy(null);
@@ -2590,12 +2611,13 @@ function Toolbar({
2590
2611
  var import_react4 = __toESM(require("react"), 1);
2591
2612
 
2592
2613
  // client/views/overlay.js
2593
- function Overlay({ t, title, children, footer, onCancel }) {
2594
- useOverlayLayer(true, onCancel);
2614
+ function Overlay({ t, title, children, footer, onCancel, dismissible = true }) {
2615
+ useOverlayLayer(true, dismissible ? onCancel : () => {
2616
+ });
2595
2617
  return portalize(h(
2596
2618
  "div",
2597
2619
  { className: "dshsc-overlay", onClick: (event) => {
2598
- if (event.target === event.currentTarget) onCancel?.();
2620
+ if (dismissible && event.target === event.currentTarget) onCancel?.();
2599
2621
  } },
2600
2622
  h(
2601
2623
  "div",
@@ -2603,9 +2625,10 @@ function Overlay({ t, title, children, footer, onCancel }) {
2603
2625
  h("button", {
2604
2626
  className: "dshsc-modal-close",
2605
2627
  "aria-label": t("action.cancel"),
2628
+ disabled: !dismissible,
2606
2629
  onClick: (event) => {
2607
2630
  event.stopPropagation();
2608
- onCancel?.();
2631
+ if (dismissible) onCancel?.();
2609
2632
  }
2610
2633
  }, "×"),
2611
2634
  h("h3", null, title),
@@ -2624,6 +2647,9 @@ var ERROR_KEY = {
2624
2647
  "error.skills.intro-timeout": "intro.error.timeout",
2625
2648
  "error.skills.intro-auth": "intro.error.auth",
2626
2649
  "error.skills.intro-empty": "intro.error.empty",
2650
+ "error.skills.intro-truncated": "intro.error.truncated",
2651
+ "error.skills.intro-reasoning-only": "intro.error.reasoningOnly",
2652
+ "error.skills.intro-no-text": "intro.error.noText",
2627
2653
  "error.skills.intro-busy": "intro.error.busy",
2628
2654
  "error.skills.intro-source": "intro.error.source",
2629
2655
  "error.skills.subscription-not-found": "intro.error.subscription",
@@ -3614,6 +3640,8 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3614
3640
  const [manual, setManual] = import_react6.default.useState("");
3615
3641
  const [skipped, setSkipped] = import_react6.default.useState(0);
3616
3642
  const [error, setError] = import_react6.default.useState(null);
3643
+ const [pending, setPending] = import_react6.default.useState(false);
3644
+ const [submitError, setSubmitError] = import_react6.default.useState(null);
3617
3645
  const reset = (value) => {
3618
3646
  setSource(value);
3619
3647
  setPhase("idle");
@@ -3621,12 +3649,14 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3621
3649
  setSelected(/* @__PURE__ */ new Set());
3622
3650
  setSkipped(0);
3623
3651
  setError(null);
3652
+ setSubmitError(null);
3624
3653
  };
3625
3654
  const probe = async () => {
3626
3655
  const value = source.trim();
3627
- if (!value || phase === "probing") return;
3656
+ if (!value || phase === "probing" || pending) return;
3628
3657
  setPhase("probing");
3629
3658
  setError(null);
3659
+ setSubmitError(null);
3630
3660
  try {
3631
3661
  const result = await onProbe(value);
3632
3662
  const found = Array.isArray(result?.skills) ? result.skills : [];
@@ -3639,20 +3669,33 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3639
3669
  setPhase("failed");
3640
3670
  }
3641
3671
  };
3642
- const toggle = (name) => setSelected((prev) => {
3643
- const next = new Set(prev);
3644
- if (next.has(name)) next.delete(name);
3645
- else next.add(name);
3646
- return next;
3647
- });
3672
+ const toggle = (name) => {
3673
+ setSubmitError(null);
3674
+ setSelected((prev) => {
3675
+ const next = new Set(prev);
3676
+ if (next.has(name)) next.delete(name);
3677
+ else next.add(name);
3678
+ return next;
3679
+ });
3680
+ };
3648
3681
  const names = manual.split(/[,,\s]+/).map((item) => item.trim()).filter(Boolean);
3649
3682
  const skippedText = skipped > 0 ? t("form.skipped").replace("{n}", String(skipped)) : null;
3650
3683
  const manualMode = phase === "empty" || phase === "failed";
3651
3684
  const skills = phase === "picked" ? [...selected] : names;
3652
3685
  const canSubmit = phase === "picked" ? selected.size > 0 : manualMode ? names.length > 0 : false;
3653
- const submit = () => {
3654
- if (!canSubmit) return;
3655
- onInstall({ source: source.trim(), skills });
3686
+ const submit = async () => {
3687
+ if (!canSubmit || pending) return;
3688
+ setPending(true);
3689
+ setSubmitError(null);
3690
+ let res;
3691
+ try {
3692
+ res = await onInstall({ source: source.trim(), skills });
3693
+ } catch (err) {
3694
+ res = { ok: false, error: err?.message || String(err) };
3695
+ }
3696
+ if (res?.ok) return;
3697
+ setPending(false);
3698
+ setSubmitError(res?.error || t("form.submitFailed"));
3656
3699
  };
3657
3700
  return h(
3658
3701
  Overlay,
@@ -3660,14 +3703,16 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3660
3703
  t,
3661
3704
  title: t("action.addSkill"),
3662
3705
  onCancel,
3706
+ // 提交安装进行中禁止关闭:请求不可取消,允许关闭只是假装取消
3707
+ dismissible: !pending,
3663
3708
  footer: [
3664
- h("button", { key: "c", className: "dshsc-btn ghost", onClick: onCancel }, t("action.cancel")),
3709
+ h("button", { key: "c", className: "dshsc-btn ghost", disabled: pending, onClick: onCancel }, t("action.cancel")),
3665
3710
  h("button", {
3666
3711
  key: "s",
3667
3712
  className: "dshsc-btn primary",
3668
- disabled: !canSubmit,
3669
- onClick: submit
3670
- }, t("action.save"))
3713
+ disabled: !canSubmit || pending,
3714
+ onClick: () => void submit()
3715
+ }, pending ? t("busy.working") : t("action.save"))
3671
3716
  ]
3672
3717
  },
3673
3718
  h("p", { className: "dshsc-sub" }, t("form.pickHint")),
@@ -3681,18 +3726,20 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3681
3726
  h("input", {
3682
3727
  value: source,
3683
3728
  placeholder: t("form.sourceHint"),
3729
+ disabled: pending,
3684
3730
  onChange: (event) => reset(event.target.value),
3685
3731
  onKeyDown: (event) => {
3686
- if (event.key === "Enter") void probe();
3732
+ if (event.key === "Enter" && !pending) void probe();
3687
3733
  }
3688
3734
  }),
3689
3735
  h("button", {
3690
3736
  className: "dshsc-btn",
3691
- disabled: !source.trim() || phase === "probing",
3737
+ disabled: !source.trim() || phase === "probing" || pending,
3692
3738
  onClick: () => void probe()
3693
3739
  }, phase === "probing" ? t("form.probing") : t("form.probe"))
3694
3740
  )
3695
3741
  ),
3742
+ submitError ? h("p", { className: "dshsc-form-error", role: "alert" }, submitError) : null,
3696
3743
  phase === "picked" ? h(
3697
3744
  "div",
3698
3745
  { key: "picked" },
@@ -3702,11 +3749,19 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3702
3749
  { style: { display: "flex", gap: 6, marginBottom: 6 } },
3703
3750
  h("button", {
3704
3751
  className: "dshsc-btn ghost",
3705
- onClick: () => setSelected(new Set(options.map((item) => item.name)))
3752
+ disabled: pending,
3753
+ onClick: () => {
3754
+ setSubmitError(null);
3755
+ setSelected(new Set(options.map((item) => item.name)));
3756
+ }
3706
3757
  }, t("form.selectAll")),
3707
3758
  h("button", {
3708
3759
  className: "dshsc-btn ghost",
3709
- onClick: () => setSelected(/* @__PURE__ */ new Set())
3760
+ disabled: pending,
3761
+ onClick: () => {
3762
+ setSubmitError(null);
3763
+ setSelected(/* @__PURE__ */ new Set());
3764
+ }
3710
3765
  }, t("form.selectNone"))
3711
3766
  ),
3712
3767
  h("div", { className: "dshsc-panel" }, options.map((option) => h(
@@ -3715,6 +3770,7 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3715
3770
  h("input", {
3716
3771
  type: "checkbox",
3717
3772
  checked: selected.has(option.name),
3773
+ disabled: pending,
3718
3774
  onChange: () => toggle(option.name)
3719
3775
  }),
3720
3776
  h(
@@ -3739,7 +3795,11 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3739
3795
  h("input", {
3740
3796
  value: manual,
3741
3797
  placeholder: "git-kit, handoff",
3742
- onChange: (event) => setManual(event.target.value)
3798
+ disabled: pending,
3799
+ onChange: (event) => {
3800
+ setManual(event.target.value);
3801
+ if (submitError) setSubmitError(null);
3802
+ }
3743
3803
  })
3744
3804
  )
3745
3805
  ) : null,
@@ -3750,23 +3810,41 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
3750
3810
  // client/views/modals.js
3751
3811
  function FormModal({ t, title, hint, fields, onCancel, onSubmit }) {
3752
3812
  const [form, setForm] = import_react7.default.useState(() => Object.fromEntries(fields.map((field) => [field.key, ""])));
3813
+ const [pending, setPending] = import_react7.default.useState(false);
3814
+ const [error, setError] = import_react7.default.useState(null);
3753
3815
  const incomplete = fields.some(
3754
3816
  (field) => field.required !== false && !String(form[field.key] || "").trim()
3755
3817
  );
3818
+ const submit = async () => {
3819
+ if (incomplete || pending) return;
3820
+ setPending(true);
3821
+ setError(null);
3822
+ let res;
3823
+ try {
3824
+ res = await onSubmit(form);
3825
+ } catch (err) {
3826
+ res = { ok: false, error: err?.message || String(err) };
3827
+ }
3828
+ if (res?.ok) return;
3829
+ setPending(false);
3830
+ setError(res?.error || t("form.submitFailed"));
3831
+ };
3756
3832
  return h(
3757
3833
  Overlay,
3758
3834
  {
3759
3835
  t,
3760
3836
  title,
3761
3837
  onCancel,
3838
+ // 提交进行中禁止关闭:请求不可取消,允许关闭只是假装取消
3839
+ dismissible: !pending,
3762
3840
  footer: [
3763
- h("button", { key: "c", className: "dshsc-btn ghost", onClick: onCancel }, t("action.cancel")),
3841
+ h("button", { key: "c", className: "dshsc-btn ghost", disabled: pending, onClick: onCancel }, t("action.cancel")),
3764
3842
  h("button", {
3765
3843
  key: "s",
3766
3844
  className: "dshsc-btn primary",
3767
- disabled: incomplete,
3768
- onClick: () => onSubmit(form)
3769
- }, t("action.save"))
3845
+ disabled: incomplete || pending,
3846
+ onClick: () => void submit()
3847
+ }, pending ? t("busy.working") : t("action.save"))
3770
3848
  ]
3771
3849
  },
3772
3850
  hint ? h("p", { className: "dshsc-sub" }, hint) : null,
@@ -3777,9 +3855,14 @@ function FormModal({ t, title, hint, fields, onCancel, onSubmit }) {
3777
3855
  h("input", {
3778
3856
  value: form[field.key],
3779
3857
  placeholder: field.placeholder,
3780
- onChange: (event) => setForm((prev) => ({ ...prev, [field.key]: event.target.value }))
3858
+ disabled: pending,
3859
+ onChange: (event) => {
3860
+ setForm((prev) => ({ ...prev, [field.key]: event.target.value }));
3861
+ if (error) setError(null);
3862
+ }
3781
3863
  })
3782
- ))
3864
+ )),
3865
+ error ? h("p", { className: "dshsc-form-error", role: "alert" }, error) : null
3783
3866
  );
3784
3867
  }
3785
3868
  function ConfirmModal({ t, title, body, onCancel, onConfirm, danger, confirmLabel, hideCancel = false }) {
@@ -3894,7 +3977,7 @@ function Modals({ t, modal, data, autoLinkAgents, actions, onClose, onSaveAutoLi
3894
3977
  { key: "subpath", label: t("form.subpath"), placeholder: "skills", required: false }
3895
3978
  ],
3896
3979
  onCancel: onClose,
3897
- onSubmit: (form) => void actions.addSubscription(form)
3980
+ onSubmit: (form) => actions.addSubscription(form)
3898
3981
  });
3899
3982
  }
3900
3983
  if (modal.type === "exports") {
@@ -33,6 +33,8 @@ function sanitizeEntry(entry) {
33
33
  inputBytes: Number.isFinite(entry.inputBytes) ? entry.inputBytes : null,
34
34
  truncated: Boolean(entry.truncated),
35
35
  sourceBytes: Number.isFinite(entry.sourceBytes) ? entry.sourceBytes : null,
36
+ maxTokens: Number.isFinite(entry.maxTokens) ? entry.maxTokens : null,
37
+ finish: typeof entry.finish === 'string' ? entry.finish : 'stop',
36
38
  };
37
39
  }
38
40
 
@@ -125,10 +127,11 @@ export function createIntroStore({ paths, logger = () => {} }) {
125
127
  return problem;
126
128
  },
127
129
  load,
128
- /** 读一条缓存;没有就是 null。返回浅拷贝,调用方改不坏内存表。 */
130
+ /** 读一条缓存;没有就是 null。只有 `finish: stop` 的完整简介才会被返回。 */
129
131
  get(name, lang) {
130
132
  const entry = load().items[name]?.[lang];
131
- return entry ? { ...entry } : null;
133
+ if (!entry || entry.finish !== 'stop') return null;
134
+ return { ...entry };
132
135
  },
133
136
  /** 写一条缓存;写盘失败不影响返回值(介绍已经拿到了)。 */
134
137
  put(name, lang, entry) {
package/lib/core/intro.js CHANGED
@@ -19,9 +19,10 @@ import { sha256 } from './hash.js';
19
19
 
20
20
  /**
21
21
  * 简介缓存的格式版本(与 state 的版本无关,坏了直接当空表)。
22
- * v3 = 简介必须写清触发方式 / 触发关键词;v2 的旧条目只讲能力,直接丢弃重建。
22
+ * v4 = 只缓存 `finish: stop` 的完整简介;v3 及更早的条目(含 300 token 上限下
23
+ * 产生的截断文本)全部丢弃重建。
23
24
  */
24
- export const INTRO_STORE_VERSION = 3;
25
+ export const INTRO_STORE_VERSION = 4;
25
26
 
26
27
  /** 介绍语言:跟随界面语言,只支持这两档。 */
27
28
  export const INTRO_LANGS = Object.freeze(['zh', 'en']);
@@ -31,20 +32,30 @@ export const INTRO_LIMITS = Object.freeze({
31
32
  minInputBytes: 8 * 1024,
32
33
  maxInputBytes: 48 * 1024,
33
34
  minOutputTokens: 96,
34
- maxOutputTokens: 1024,
35
+ maxOutputTokens: 8_192,
35
36
  minTimeoutMs: 5_000,
36
37
  maxTimeoutMs: 60_000,
37
38
  /** 单条简介的字符上限(提示词要 160 字并含触发句,留出余量后兜底) */
38
39
  maxIntroChars: 400,
39
40
  });
40
41
 
42
+ /**
43
+ * 输出预算的保底值:`maxOutputTokens` 是「防跑飞上限」,不是长度目标。
44
+ * 推理模型的思考 token 与正文共用同一份输出预算,300 这种小上限会被思考吃光;
45
+ * 因此推理模型至少 4096,普通模型至少 1024(400 字符中文也可能超过 300 token)。
46
+ */
47
+ export const INTRO_OUTPUT_FLOOR = Object.freeze({
48
+ plain: 1_024,
49
+ reasoning: 4_096,
50
+ });
51
+
41
52
  export const DEFAULT_INTRO_CONFIG = Object.freeze({
42
53
  enabled: true,
43
54
  provider: null,
44
55
  model: null,
45
56
  maxInputBytes: 24 * 1024,
46
- maxOutputTokens: 300,
47
- timeoutMs: 30_000,
57
+ maxOutputTokens: 4_096,
58
+ timeoutMs: 60_000,
48
59
  });
49
60
 
50
61
  /** 调用/准备阶段的失败,由 Service 转成带 HTTP 状态的错误。 */
@@ -326,7 +337,7 @@ function failureFrom(error) {
326
337
  * @param {{system: string, input: string}} options.prompt
327
338
  * @param {number} options.maxOutputTokens
328
339
  * @param {AbortSignal} [options.signal]
329
- * @returns {Promise<{ text: string, usage: object|null, provider: string, model: string }>}
340
+ * @returns {Promise<{ text: string, usage: object|null, provider: string, model: string, maxTokens: number, reasoningEffort: string|null, finish: string }>}
330
341
  */
331
342
  export async function runIntroCall({ llm, route, prompt, maxOutputTokens, signal }) {
332
343
  if (!llm || (typeof llm.prepareCall !== 'function' && typeof llm.stream !== 'function')) {
@@ -345,16 +356,29 @@ export async function runIntroCall({ llm, route, prompt, maxOutputTokens, signal
345
356
  },
346
357
  ];
347
358
 
359
+ // 输出上限是「防跑飞预算」,不是长度目标:推理 token 也计入这里。
360
+ // 推理模型至少 4096,普通模型至少 1024,避免思考吃光预算后正文为空。
361
+ const requestedMaxTokens = Math.max(
362
+ Number.isFinite(maxOutputTokens) ? maxOutputTokens : DEFAULT_INTRO_CONFIG.maxOutputTokens,
363
+ INTRO_OUTPUT_FLOOR.plain,
364
+ );
365
+
348
366
  let call = null;
349
367
  try {
350
368
  if (typeof llm.prepareCall === 'function') {
351
369
  call = await llm.prepareCall(
352
- { provider: route.provider, model: route.model, maxTokens: maxOutputTokens },
370
+ { provider: route.provider, model: route.model, maxTokens: requestedMaxTokens },
353
371
  signal,
354
372
  (controls, model) => {
373
+ const floor = model?.reasoning ? INTRO_OUTPUT_FLOOR.reasoning : INTRO_OUTPUT_FLOOR.plain;
374
+ const modelCap = Number.isFinite(model?.defaultMaxTokens) ? model.defaultMaxTokens : Infinity;
355
375
  // 介绍不需要长思考:取适配器公布的最低推理强度,压低首字延迟与成本。
356
376
  const lowest = model?.reasoning?.efforts?.[0]?.id;
357
- return lowest ? { ...controls, reasoningEffort: lowest } : controls;
377
+ return {
378
+ ...controls,
379
+ maxTokens: Math.max(controls.maxTokens ?? 0, Math.min(floor, modelCap)),
380
+ ...(lowest ? { reasoningEffort: lowest } : {}),
381
+ };
358
382
  },
359
383
  );
360
384
  }
@@ -362,42 +386,96 @@ export async function runIntroCall({ llm, route, prompt, maxOutputTokens, signal
362
386
  throw failureFrom(error);
363
387
  }
364
388
 
389
+ const fallbackMaxTokens = Math.max(requestedMaxTokens, INTRO_OUTPUT_FLOOR.reasoning);
365
390
  const options = call
366
391
  ? { ...call.config, messages, system: prompt.system, signal }
367
392
  : {
368
393
  provider: route.provider,
369
394
  model: route.model,
370
- maxTokens: maxOutputTokens,
395
+ maxTokens: fallbackMaxTokens,
371
396
  messages,
372
397
  system: prompt.system,
373
398
  signal,
374
399
  };
375
400
 
376
- let text = '';
401
+ let deltaText = '';
402
+ let sawTextDelta = false;
403
+ const textBlocks = new Map();
404
+ let reasoningChars = 0;
377
405
  let usage = null;
378
406
  let finish = null;
379
407
  try {
380
408
  const stream = call ? call.stream(options) : llm.stream(options);
381
409
  for await (const chunk of stream) {
382
410
  if (!chunk || typeof chunk !== 'object') continue;
383
- if (chunk.type === 'text-delta') text += typeof chunk.text === 'string' ? chunk.text : '';
384
- else if (chunk.type === 'usage') usage = chunk.usage || null;
385
- else if (chunk.type === 'finish') finish = chunk.reason || null;
411
+ if (chunk.type === 'text-delta') {
412
+ sawTextDelta = true;
413
+ if (typeof chunk.text === 'string') deltaText += chunk.text;
414
+ } else if (chunk.type === 'reasoning-delta') {
415
+ if (typeof chunk.text === 'string') reasoningChars += chunk.text.length;
416
+ } else if (chunk.type === 'block-end') {
417
+ const block = chunk.block;
418
+ if (block?.type === 'text' && typeof block.text === 'string') {
419
+ textBlocks.set(chunk.index, block.text);
420
+ } else if (block?.type === 'reasoning' && typeof block.text === 'string') {
421
+ reasoningChars = Math.max(reasoningChars, block.text.length);
422
+ }
423
+ } else if (chunk.type === 'usage') {
424
+ usage = chunk.usage || null;
425
+ } else if (chunk.type === 'finish') {
426
+ finish = chunk.reason || null;
427
+ }
386
428
  }
387
429
  } catch (error) {
388
430
  throw failureFrom(error);
389
431
  }
390
432
 
391
- if (finish && finish.kind !== 'stop' && finish.kind !== 'max-tokens') {
392
- throw failureFrom({ failure: finish.failure || null, code: finish.kind === 'aborted' ? 'ABORTED' : null });
433
+ const effectiveMaxTokens = Number.isFinite(options.maxTokens) ? options.maxTokens : requestedMaxTokens;
434
+ const kind = finish?.kind || 'stop';
435
+
436
+ if (kind === 'aborted') {
437
+ throw failureFrom({ failure: finish?.failure || null, code: 'ABORTED' });
438
+ }
439
+ if (kind === 'error') {
440
+ throw failureFrom({ failure: finish?.failure || null, code: null });
441
+ }
442
+ if (kind === 'max-tokens') {
443
+ throw new IntroError(
444
+ 'error.skills.intro-truncated',
445
+ '简介达到输出上限被截断,请重试或调大 intro.maxOutputTokens',
446
+ { finish: kind, maxTokens: effectiveMaxTokens, usage },
447
+ 502,
448
+ );
449
+ }
450
+ if (kind === 'tool-calls') {
451
+ throw new IntroError(
452
+ 'error.skills.intro-no-text',
453
+ '模型没有输出文字简介(尝试调用工具),请重试',
454
+ { finish: kind, maxTokens: effectiveMaxTokens, usage },
455
+ 502,
456
+ );
457
+ }
458
+ if (kind !== 'stop') {
459
+ throw failureFrom({ failure: finish?.failure || null, code: null });
393
460
  }
394
461
 
462
+ // 块式适配器可能不给 text-delta,回退用 block-end 里的 text block。
463
+ const text = sawTextDelta ? deltaText : [...textBlocks.values()].join('');
395
464
  const cleaned = cleanIntroText(text);
396
465
  if (!cleaned) {
466
+ const produced = reasoningChars > 0 || Number(usage?.outputTokens) > 0;
467
+ if (produced) {
468
+ throw new IntroError(
469
+ 'error.skills.intro-reasoning-only',
470
+ '模型只输出了思考过程、没有输出简介正文,请提高输出上限或换一个非推理模型',
471
+ { finish: kind, maxTokens: effectiveMaxTokens, usage, reasoningChars },
472
+ 502,
473
+ );
474
+ }
397
475
  throw new IntroError(
398
476
  'error.skills.intro-empty',
399
- '模型没有返回内容,请重试或换一个模型',
400
- { finish: finish?.kind || null },
477
+ '模型没有返回内容,请重试',
478
+ { finish: kind, maxTokens: effectiveMaxTokens, usage },
401
479
  502,
402
480
  );
403
481
  }
@@ -407,6 +485,9 @@ export async function runIntroCall({ llm, route, prompt, maxOutputTokens, signal
407
485
  usage,
408
486
  provider: route.provider,
409
487
  model: route.model,
488
+ maxTokens: effectiveMaxTokens,
489
+ reasoningEffort: options.reasoningEffort || null,
490
+ finish: kind,
410
491
  };
411
492
  }
412
493
 
@@ -416,7 +497,18 @@ export function introKey(name, lang) {
416
497
  }
417
498
 
418
499
  /** 汇总一次生成的元信息(缓存条目与响应共用)。 */
419
- export function introEntry({ text, hash, provider, model, usage, inputBytes, truncated, sourceBytes }) {
500
+ export function introEntry({
501
+ text,
502
+ hash,
503
+ provider,
504
+ model,
505
+ usage,
506
+ inputBytes,
507
+ truncated,
508
+ sourceBytes,
509
+ maxTokens = null,
510
+ finish = 'stop',
511
+ }) {
420
512
  return {
421
513
  hash,
422
514
  text,
@@ -427,6 +519,8 @@ export function introEntry({ text, hash, provider, model, usage, inputBytes, tru
427
519
  inputBytes,
428
520
  truncated: Boolean(truncated),
429
521
  sourceBytes: sourceBytes ?? inputBytes,
522
+ maxTokens: Number.isFinite(maxTokens) ? maxTokens : null,
523
+ finish,
430
524
  };
431
525
  }
432
526
 
@@ -1514,6 +1514,8 @@ export function createService({
1514
1514
  inputBytes: prompt.inputBytes,
1515
1515
  truncated: prompt.truncated,
1516
1516
  sourceBytes: prompt.sourceBytes,
1517
+ maxTokens: result.maxTokens,
1518
+ finish: result.finish,
1517
1519
  });
1518
1520
  introStore.put(name, language, entry);
1519
1521
  logger('intro.generate', {
@@ -1521,6 +1523,9 @@ export function createService({
1521
1523
  lang: language,
1522
1524
  provider: result.provider,
1523
1525
  model: result.model,
1526
+ effort: result.reasoningEffort || null,
1527
+ maxTokens: result.maxTokens ?? null,
1528
+ finish: result.finish || null,
1524
1529
  inputBytes: prompt.inputBytes,
1525
1530
  truncated: prompt.truncated,
1526
1531
  durationMs: Date.now() - startedAt,
@@ -1536,7 +1541,12 @@ export function createService({
1536
1541
  };
1537
1542
  } catch (error) {
1538
1543
  if (!(error instanceof ServiceError)) {
1539
- logger('intro.generate.failed', { name, message: error?.message, code: error?.code });
1544
+ logger('intro.generate.failed', {
1545
+ name,
1546
+ message: error?.message,
1547
+ code: error?.code,
1548
+ detail: error?.detail || null,
1549
+ });
1540
1550
  }
1541
1551
  throw error;
1542
1552
  }
@@ -1648,6 +1658,8 @@ export function createService({
1648
1658
  inputBytes: prompt.inputBytes,
1649
1659
  truncated: prompt.truncated,
1650
1660
  sourceBytes: prompt.sourceBytes,
1661
+ maxTokens: result.maxTokens,
1662
+ finish: result.finish,
1651
1663
  });
1652
1664
  introStore.put(target.cacheKey, language, entry);
1653
1665
  logger('intro.generate.remote', {
@@ -1657,6 +1669,9 @@ export function createService({
1657
1669
  commit: target.commit,
1658
1670
  provider: result.provider,
1659
1671
  model: result.model,
1672
+ effort: result.reasoningEffort || null,
1673
+ maxTokens: result.maxTokens ?? null,
1674
+ finish: result.finish || null,
1660
1675
  inputBytes: prompt.inputBytes,
1661
1676
  truncated: prompt.truncated,
1662
1677
  durationMs: Date.now() - startedAt,
@@ -1681,6 +1696,7 @@ export function createService({
1681
1696
  name,
1682
1697
  message: error?.message,
1683
1698
  code: error?.code,
1699
+ detail: error?.detail || null,
1684
1700
  });
1685
1701
  }
1686
1702
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "morehao <morehao@qq.com>",