@liyang8246/dsh-accounts 0.1.8 → 0.1.9

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/client.js CHANGED
@@ -27,7 +27,9 @@
27
27
  const en = {
28
28
  title: "Model Accounts",
29
29
  refresh: "Refresh quota",
30
- refreshAll: "Refresh all quotas",
30
+ modelFilter: "Model name filter",
31
+ modelFilterPlaceholder: "e.g. deepseek gpt minimax",
32
+ modelFilterHint: "Space-separated keywords, case-insensitive. The chat model picker only offers models whose name contains one of them; empty offers every model.",
31
33
  add: "Add account",
32
34
  label: "Account label",
33
35
  key: "API key",
@@ -57,7 +59,9 @@
57
59
  const zh = {
58
60
  title: "模型账号",
59
61
  refresh: "刷新额度",
60
- refreshAll: "刷新全部额度",
62
+ modelFilter: "模型筛选",
63
+ modelFilterPlaceholder: "例如 deepseek gpt minimax",
64
+ modelFilterHint: "用空格分隔关键词,不区分大小写。聊天框的模型选择只列出名称含任一关键词的模型;留空列出全部。",
61
65
  add: "添加账号",
62
66
  label: "账号名称",
63
67
  key: "API Key",
@@ -106,6 +110,7 @@
106
110
  .zph-accounts .zph-quota{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}.zph-accounts .zph-window{padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-size:11px;overflow-wrap:anywhere}.zph-accounts .zph-window-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}
107
111
  .zph-accounts .zph-track{margin:6px 0 0;width:100%}.zph-accounts .zph-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere}.zph-accounts .zph-quota-unavailable{padding-left:5px}.zph-accounts .zph-quota-unavailable > p{display:inline}.zph-accounts .zph-quota-unavailable > p + p{margin-left:8px}
108
112
  .zph-accounts .zph-add{padding:12px 16px;border-top:1px solid var(--dsw-alias-border-l1)}.zph-accounts .zph-form{display:flex;flex-direction:column;gap:10px;margin-top:10px}.zph-accounts .zph-form label{display:flex;flex-direction:column;gap:4px}.zph-accounts .zph-grow{flex:1 1 180px;min-width:0}.zph-accounts .zph-align-end{align-items:flex-end}
113
+ .zph-accounts .zph-filter{display:flex;flex-direction:column;gap:4px}.zph-accounts .zph-filter label{display:flex;flex-direction:column;gap:4px}.zph-accounts .zph-filter input{width:100%}
109
114
  .zph-accounts .zph-switch{display:flex;align-items:center;cursor:pointer}.zph-accounts .zph-switch input{appearance:none;-webkit-appearance:none;position:relative;width:36px;height:20px;min-height:0;padding:0;border:0;border-radius:10px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;transition:background .15s}
110
115
  .zph-accounts .zph-switch input::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-secondary);transition:transform .15s,background .15s}
111
116
  .zph-accounts .zph-switch input:checked{background:var(--dsw-alias-label-primary)}.zph-accounts .zph-switch input:checked::after{transform:translateX(16px);background:var(--dsw-alias-bg-base)}.zph-accounts .zph-switch input:disabled{opacity:.5;cursor:default}
@@ -150,7 +155,7 @@
150
155
  return isObject(value) && typeof value.id === "string" && isChannelId(value.channel) && (value.status === "pending" || value.status === "authorized" || value.status === "cancelled" || value.status === "error") && optionalString(value.prompt) && optionalString(value.error) && (value.notice === void 0 || isNotice(value.notice));
151
156
  }
152
157
  function isSnapshot(value) {
153
- return isObject(value) && Array.isArray(value.channels) && value.channels.every(isChannel) && Array.isArray(value.accounts) && value.accounts.every(isAccount) && isObject(value.catalog) && typeof value.catalog.source === "string" && typeof value.catalog.generatedAt === "string" && typeof value.catalog.sha256 === "string" && (value.login === void 0 || isLogin(value.login));
158
+ return isObject(value) && Array.isArray(value.channels) && value.channels.every(isChannel) && Array.isArray(value.accounts) && value.accounts.every(isAccount) && isObject(value.catalog) && typeof value.catalog.source === "string" && typeof value.catalog.generatedAt === "string" && typeof value.catalog.sha256 === "string" && optionalString(value.filter) && (value.login === void 0 || isLogin(value.login));
154
159
  }
155
160
  function readSnapshot(value) {
156
161
  if (!isSnapshot(value)) throw new Error("Invalid account response");
@@ -225,11 +230,12 @@
225
230
  return isLoginRequest(request) ? readLogin(value) : readSnapshot(value);
226
231
  }
227
232
  // src/client/accounts-page.tsx
228
- function createAccountsPage(React, ctx, store, t, Quota) {
233
+ function createAccountsPage(React, ctx, store, t, Quota, filter) {
229
234
  const errorMessage = (error) => error instanceof Error ? error.message : t("failed");
230
235
  return function AccountsPage() {
231
236
  React.useSyncExternalStore((fn) => ctx.locale.subscribe(fn), () => ctx.locale.getSnapshot());
232
237
  const data = React.useSyncExternalStore(store.subscribe, store.getSnapshot);
238
+ const filterText = React.useSyncExternalStore(filter.subscribe, filter.getSnapshot);
233
239
  const [busy, setBusy] = React.useState(false);
234
240
  const [error, setError] = React.useState("");
235
241
  const [form, setForm] = React.useState(null);
@@ -393,7 +399,14 @@
393
399
  className: "zph-accounts",
394
400
  "aria-label": t("title"),
395
401
  "aria-busy": busy
396
- }, /* @__PURE__ */ React.createElement("style", null, css), /* @__PURE__ */ React.createElement("div", { className: "zph-row zph-between" }, /* @__PURE__ */ React.createElement("h2", null, t("title")), button(t("refreshAll"), { action: "refresh_quota" })), error && /* @__PURE__ */ React.createElement("p", {
402
+ }, /* @__PURE__ */ React.createElement("style", null, css), /* @__PURE__ */ React.createElement("h2", null, t("title")), /* @__PURE__ */ React.createElement("div", { className: "zph-filter" }, /* @__PURE__ */ React.createElement("label", null, /* @__PURE__ */ React.createElement("span", null, t("modelFilter")), /* @__PURE__ */ React.createElement("input", {
403
+ type: "text",
404
+ value: filterText,
405
+ placeholder: t("modelFilterPlaceholder"),
406
+ autoComplete: "off",
407
+ spellCheck: false,
408
+ onChange: (event) => filter.setText(event.currentTarget.value)
409
+ })), /* @__PURE__ */ React.createElement("p", { className: "zph-muted" }, t("modelFilterHint"))), error && /* @__PURE__ */ React.createElement("p", {
397
410
  role: "alert",
398
411
  className: "zph-error"
399
412
  }, error), !data ? /* @__PURE__ */ React.createElement("p", { role: "status" }, t("loading")) : data.channels.map((channel) => {
@@ -548,6 +561,158 @@
548
561
  return /* @__PURE__ */ React.createElement(QuotaBars, { windows: activeQuota(data, channel) });
549
562
  };
550
563
  }
564
+ // src/client/model-filter.ts
565
+ /** Typing must not write the credential record per keystroke. */
566
+ const saveDelay = 400;
567
+ function keywordsOf(text) {
568
+ return [...new Set(text.toLowerCase().split(/\s+/).filter(Boolean))];
569
+ }
570
+ /**
571
+ * Create the composer model filter.
572
+ * @param save - persists a settled text change; typing alone never reaches it.
573
+ * @returns the filter, hydrated once from the Host's saved text.
574
+ */
575
+ function createModelFilter(save) {
576
+ const listeners = /* @__PURE__ */ new Set();
577
+ let text = "";
578
+ let parsed = [];
579
+ let hydrated = false;
580
+ let dirty = false;
581
+ let timer;
582
+ const publish = () => {
583
+ for (const listener of [...listeners]) listener();
584
+ };
585
+ return {
586
+ getSnapshot: () => text,
587
+ subscribe(listener) {
588
+ listeners.add(listener);
589
+ return () => {
590
+ listeners.delete(listener);
591
+ };
592
+ },
593
+ hydrate(value) {
594
+ if (hydrated) return;
595
+ hydrated = true;
596
+ const next = value.trim();
597
+ if (dirty || next === text) return;
598
+ text = next;
599
+ parsed = keywordsOf(next);
600
+ publish();
601
+ },
602
+ setText(next) {
603
+ const value = next.trim();
604
+ dirty = true;
605
+ if (value === text) return;
606
+ text = value;
607
+ parsed = keywordsOf(value);
608
+ publish();
609
+ if (timer !== void 0) clearTimeout(timer);
610
+ timer = setTimeout(() => {
611
+ timer = void 0;
612
+ save?.(value);
613
+ }, saveDelay);
614
+ },
615
+ keywords: () => parsed,
616
+ dispose() {
617
+ if (timer !== void 0) clearTimeout(timer);
618
+ timer = void 0;
619
+ listeners.clear();
620
+ }
621
+ };
622
+ }
623
+ function matches(name, keywords) {
624
+ const lower = name.toLowerCase();
625
+ return keywords.some((keyword) => lower.includes(keyword));
626
+ }
627
+ /**
628
+ * Keep only models whose name contains one of the keywords.
629
+ * @param state - the session's model directory snapshot.
630
+ * @param keywords - lowercase keywords; empty keeps everything.
631
+ * @returns the snapshot with filtered provider groups; the selected model stays visible so the composer trigger keeps naming the model in use.
632
+ */
633
+ function filterModels(state, keywords) {
634
+ if (!keywords.length) return state;
635
+ const groups = state.groups.map((group) => ({
636
+ ...group,
637
+ models: group.models.filter((model) => matches(model.name, keywords))
638
+ }));
639
+ const { current } = state;
640
+ if (current !== null) {
641
+ const source = state.groups.find((group) => group.id === current.provider);
642
+ const model = source?.models.find((item) => item.id === current.model);
643
+ if (source !== void 0 && model !== void 0) {
644
+ const target = groups.find((group) => group.id === source.id);
645
+ if (target === void 0) groups.push({
646
+ ...source,
647
+ models: [model]
648
+ });
649
+ else if (!target.models.some((item) => item.id === model.id)) target.models = [...target.models, model];
650
+ }
651
+ }
652
+ return {
653
+ ...state,
654
+ groups: groups.filter((group) => group.models.length > 0)
655
+ };
656
+ }
657
+ /**
658
+ * Filter every session model directory the composer seat and /model popup share.
659
+ * Both entries read the directory's store, so the filter sits on the store
660
+ * instead of on either surface.
661
+ * @param ctx - client root context.
662
+ * @param filter - the page-owned keyword filter.
663
+ */
664
+ function installModelFilter(ctx, filter) {
665
+ const models = ctx.get("modelDirectories");
666
+ if (models === void 0) return;
667
+ const original = models.directoryFor;
668
+ const patched = /* @__PURE__ */ new WeakSet();
669
+ models.directoryFor = (sessionId) => {
670
+ const directory = original.call(models, sessionId);
671
+ if (!patched.has(directory)) {
672
+ patched.add(directory);
673
+ filterDirectory(directory, filter);
674
+ }
675
+ return directory;
676
+ };
677
+ ctx.effect(() => () => {
678
+ models.directoryFor = original;
679
+ });
680
+ }
681
+ function filterDirectory(directory, filter) {
682
+ const source = directory.store;
683
+ let cache;
684
+ const read = () => {
685
+ const snapshot = source.getSnapshot();
686
+ const key = filter.getSnapshot();
687
+ if (cache === void 0 || cache.snapshot !== snapshot || cache.key !== key) cache = {
688
+ snapshot,
689
+ key,
690
+ value: filterModels(snapshot, filter.keywords())
691
+ };
692
+ return cache.value;
693
+ };
694
+ const wrapped = {
695
+ getSnapshot: read,
696
+ subscribe(listener) {
697
+ const stopSource = source.subscribe(listener);
698
+ const stopFilter = filter.subscribe(listener);
699
+ return () => {
700
+ stopSource();
701
+ stopFilter();
702
+ };
703
+ },
704
+ set: (next) => {
705
+ source.set(next);
706
+ },
707
+ update: (mutator) => {
708
+ source.update(mutator);
709
+ }
710
+ };
711
+ Object.defineProperty(directory, "store", {
712
+ configurable: true,
713
+ get: () => wrapped
714
+ });
715
+ }
551
716
  // src/client/quota.tsx
552
717
  function createQuotaComponents(React, t) {
553
718
  function Quota({ quota }) {
@@ -669,7 +834,16 @@
669
834
  ctx.effect(() => () => store.dispose());
670
835
  const { Quota, QuotaBars } = createQuotaComponents(React, t);
671
836
  const ComposerQuota = createComposerQuota(React, ctx, store, QuotaBars);
672
- const AccountsPage = createAccountsPage(React, ctx, store, t, Quota);
837
+ const filter = createModelFilter((text) => {
838
+ call({
839
+ action: "set_filter",
840
+ filter: text
841
+ }).catch(() => {});
842
+ });
843
+ ctx.effect(() => () => filter.dispose());
844
+ installModelFilter(ctx, filter);
845
+ store.reload().then((snapshot) => filter.hydrate(snapshot.filter ?? "")).catch(() => {});
846
+ const AccountsPage = createAccountsPage(React, ctx, store, t, Quota, filter);
673
847
  ctx.slots.inject("settings.section", () => ctx.slots.register({
674
848
  name: "settings.section",
675
849
  id: "zephyr-accounts",
package/lib/index.js CHANGED
@@ -1395,6 +1395,11 @@ function record(value, label = "request") {
1395
1395
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid ${label}`);
1396
1396
  return value;
1397
1397
  }
1398
+ /** Model filter text; empty clears the filter, so `text()` cannot validate it. */
1399
+ function filterText(value) {
1400
+ if (typeof value !== "string" || value.length > 1024 || [...value].some((character) => character.charCodeAt(0) < 32 || character === "")) throw new Error("Invalid model filter");
1401
+ return value.trim();
1402
+ }
1398
1403
  function validateTokens(value) {
1399
1404
  const tokens = record(value, "OAuth tokens");
1400
1405
  const access = text(tokens.access, "access token", 32768);
@@ -6945,7 +6950,8 @@ function readState(payload) {
6945
6950
  version: 1,
6946
6951
  accounts,
6947
6952
  active: { ...active },
6948
- quotas: readQuotas(state.quotas)
6953
+ quotas: readQuotas(state.quotas),
6954
+ ...typeof state.filter === "string" && state.filter.trim() ? { filter: state.filter.trim() } : {}
6949
6955
  };
6950
6956
  }
6951
6957
  function stateFrom(stored) {
@@ -7056,6 +7062,15 @@ var Accounts = class {
7056
7062
  async quotas() {
7057
7063
  return (await this.read()).quotas ?? {};
7058
7064
  }
7065
+ async filter() {
7066
+ return (await this.read()).filter ?? "";
7067
+ }
7068
+ async saveFilter(filter) {
7069
+ await this.change((state) => {
7070
+ if (filter) state.filter = filter;
7071
+ else delete state.filter;
7072
+ });
7073
+ }
7059
7074
  async saveQuota(id, quota) {
7060
7075
  await this.change((state) => {
7061
7076
  if (!state.accounts.some((account) => account.id === id)) return;
@@ -7254,6 +7269,7 @@ var ModelAccountsService = class {
7254
7269
  async snapshot() {
7255
7270
  const views = await this.accounts.list();
7256
7271
  const saved = await this.accounts.quotas();
7272
+ const filter = await this.accounts.filter();
7257
7273
  return {
7258
7274
  channels,
7259
7275
  accounts: views.map((account) => {
@@ -7263,7 +7279,8 @@ var ModelAccountsService = class {
7263
7279
  quota
7264
7280
  } : account;
7265
7281
  }),
7266
- catalog: catalogMetadata
7282
+ catalog: catalogMetadata,
7283
+ filter
7267
7284
  };
7268
7285
  }
7269
7286
  async activeChannels() {
@@ -7357,6 +7374,10 @@ var ModelAccountsService = class {
7357
7374
  if (origin !== "ui") throw new Error("Resetting quota is user-only; open Settings → Model Accounts");
7358
7375
  await this.resetCredit(text(args.id, "account id"), signal);
7359
7376
  break;
7377
+ case "set_filter":
7378
+ if (origin !== "ui") throw new Error("The model filter is user-only; open Settings → Model Accounts");
7379
+ await this.accounts.saveFilter(filterText(args.filter));
7380
+ break;
7360
7381
  case "refresh_quota": {
7361
7382
  const accounts = await this.accounts.list();
7362
7383
  const ids = args.id === void 0 ? accounts.map((account) => account.id) : [text(args.id, "account id")];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liyang8246/dsh-accounts",
3
3
  "type": "module",
4
- "version": "0.1.8",
4
+ "version": "0.1.9",
5
5
  "description": "Independent multi-account Codex, OpenCode Go and CommandCode Go plugin for DeepSeek Harness.",
6
6
  "repository": { "type": "git", "url": "https://github.com/liyang8246/dsh-zolin.git", "directory": "packages/dsh-accounts" },
7
7
  "exports": {