@objectstack/service-ai 6.8.1 → 7.0.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/dist/index.cjs CHANGED
@@ -1524,6 +1524,36 @@ function finishPart(result) {
1524
1524
  rawFinishReason: "stop"
1525
1525
  };
1526
1526
  }
1527
+ function extractMessageText(message) {
1528
+ const c = message.content;
1529
+ if (typeof c === "string") return c;
1530
+ if (!Array.isArray(c)) return "";
1531
+ const parts = [];
1532
+ for (const part of c) {
1533
+ if (typeof part === "string") {
1534
+ parts.push(part);
1535
+ } else if (part && typeof part === "object") {
1536
+ const p = part;
1537
+ if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
1538
+ }
1539
+ }
1540
+ return parts.join(" ").trim();
1541
+ }
1542
+ function cleanTitle(raw, maxLen) {
1543
+ let s = raw.replace(/\s+/g, " ").trim();
1544
+ s = s.replace(/^[\s"'“”‘’`「『((\[【]+/, "").replace(/[\s"'“”‘’`」』))\]】]+$/, "");
1545
+ s = s.replace(/^(title|标题|主题)\s*[::]\s*/i, "");
1546
+ s = s.replace(/^[\s"'“”‘’`「『((\[【]+/, "").replace(/[\s"'“”‘’`」』))\]】]+$/, "");
1547
+ s = s.replace(/[.。!!??,,;;::]+$/, "").trim();
1548
+ if (!s) return "";
1549
+ if (s.length <= maxLen) return s;
1550
+ if (/^[\x00-\x7F]+$/.test(s)) {
1551
+ const cut = s.slice(0, maxLen);
1552
+ const lastSpace = cut.lastIndexOf(" ");
1553
+ return lastSpace > maxLen / 2 ? cut.slice(0, lastSpace) : cut;
1554
+ }
1555
+ return s.slice(0, maxLen);
1556
+ }
1527
1557
  var _AIService = class _AIService {
1528
1558
  constructor(config = {}) {
1529
1559
  /**
@@ -1533,6 +1563,23 @@ var _AIService = class _AIService {
1533
1563
  * through `approvePendingAction()`.
1534
1564
  */
1535
1565
  this.pendingDispatchers = /* @__PURE__ */ new Map();
1566
+ /**
1567
+ * Auto-title configuration. When `enabled`, the first `chatWithTools` /
1568
+ * `streamChatWithTools` call against a still-untitled conversation
1569
+ * triggers a one-shot LLM call (fire-and-forget) that summarises the
1570
+ * exchange into a short title and PATCHes it onto the conversation row.
1571
+ *
1572
+ * Defaults to disabled — `AIServicePlugin` flips this on (with values
1573
+ * read from the `ai` settings namespace) once the kernel is ready.
1574
+ * Keeping the default off means unit tests don't accidentally make
1575
+ * extra adapter calls.
1576
+ */
1577
+ this.titleGeneration = {
1578
+ enabled: false,
1579
+ maxLength: 16
1580
+ };
1581
+ /** Tracks conversations we've already attempted to title to avoid duplicate LLM calls. */
1582
+ this.titledConversations = /* @__PURE__ */ new Set();
1536
1583
  this.adapter = config.adapter ?? new MemoryLLMAdapter();
1537
1584
  this.logger = config.logger ?? (0, import_core.createLogger)({ level: "info", format: "pretty" });
1538
1585
  this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
@@ -1561,6 +1608,89 @@ var _AIService = class _AIService {
1561
1608
  this.logger.info(`[AI] LLM adapter swapped: ${prev} \u2192 ${next.name}`);
1562
1609
  }
1563
1610
  }
1611
+ /**
1612
+ * Configure conversation auto-titling. Called by `AIServicePlugin`
1613
+ * when the `ai` settings namespace is bound (so admins can toggle
1614
+ * the feature live from the Setup app without a restart).
1615
+ *
1616
+ * - `enabled=false` is the safe default for unit tests and the
1617
+ * memory adapter (which would just echo the prompt back as a title).
1618
+ * - `maxLength` is enforced both in the prompt and as a hard server-side
1619
+ * `slice()` so a misbehaving model can't write a 4 KB "title".
1620
+ */
1621
+ setTitleGenerationConfig(config) {
1622
+ this.titleGeneration = {
1623
+ enabled: config.enabled,
1624
+ maxLength: Math.max(8, Math.min(80, config.maxLength ?? 16))
1625
+ };
1626
+ this.logger.debug("[AI] title generation config", this.titleGeneration);
1627
+ }
1628
+ /**
1629
+ * Best-effort title generation for a conversation. Idempotent per
1630
+ * `AIService` instance — once attempted, the id is recorded in
1631
+ * `titledConversations` so subsequent chats don't burn extra tokens
1632
+ * re-summarising the same thread.
1633
+ *
1634
+ * Skips when:
1635
+ * - feature disabled
1636
+ * - conversation already has a non-empty title
1637
+ * - conversation has fewer than 2 messages (no exchange to summarise)
1638
+ * - conversation has no user message
1639
+ *
1640
+ * Failures are logged at debug level and swallowed — title generation
1641
+ * is purely cosmetic and must never break chat.
1642
+ */
1643
+ async summarizeConversation(conversationId) {
1644
+ if (!this.titleGeneration.enabled) return;
1645
+ if (this.titledConversations.has(conversationId)) return;
1646
+ this.titledConversations.add(conversationId);
1647
+ try {
1648
+ const conv = await this.conversationService.get(conversationId);
1649
+ if (!conv) return;
1650
+ if (conv.title && conv.title.trim().length > 0) return;
1651
+ if (!conv.messages || conv.messages.length < 2) return;
1652
+ const userMsg = conv.messages.find((m) => m.role === "user");
1653
+ const assistantMsg = conv.messages.find((m) => m.role === "assistant");
1654
+ if (!userMsg) return;
1655
+ const userText = extractMessageText(userMsg);
1656
+ const assistantText = assistantMsg ? extractMessageText(assistantMsg) : "";
1657
+ if (!userText) return;
1658
+ const maxLen = this.titleGeneration.maxLength;
1659
+ const prompt = [
1660
+ {
1661
+ role: "system",
1662
+ content: `You are a title generator. Produce a SHORT (<=${maxLen} characters), noun-phrase title that captures the topic of the conversation below. Reply with the title ONLY \u2014 no quotes, no punctuation, no preamble, no trailing period. Match the language of the user's message.`
1663
+ },
1664
+ {
1665
+ role: "user",
1666
+ content: `User said:
1667
+ ${userText.slice(0, 800)}` + (assistantText ? `
1668
+
1669
+ Assistant replied:
1670
+ ${assistantText.slice(0, 800)}` : "")
1671
+ }
1672
+ ];
1673
+ const result = await this.adapter.chat(prompt, {
1674
+ temperature: 0.3,
1675
+ maxTokens: 32
1676
+ });
1677
+ const raw = (result.content ?? "").trim();
1678
+ if (!raw) return;
1679
+ const cleaned = cleanTitle(raw, maxLen);
1680
+ if (!cleaned) return;
1681
+ await this.conversationService.update(conversationId, { title: cleaned });
1682
+ this.logger.debug("[AI] auto-titled conversation", {
1683
+ conversationId,
1684
+ title: cleaned
1685
+ });
1686
+ } catch (err) {
1687
+ this.titledConversations.delete(conversationId);
1688
+ this.logger.debug("[AI] summarizeConversation failed", {
1689
+ conversationId,
1690
+ error: err instanceof Error ? err.message : String(err)
1691
+ });
1692
+ }
1693
+ }
1564
1694
  /**
1565
1695
  * Best-effort auto-creation of a conversation when the caller did not
1566
1696
  * supply one but did supply an actor we can attribute the chat to.
@@ -1761,6 +1891,7 @@ var _AIService = class _AIService {
1761
1891
  role: "assistant",
1762
1892
  content: result.content
1763
1893
  });
1894
+ void this.summarizeConversation(conversationId);
1764
1895
  }
1765
1896
  return autoCreatedConversationId ? { ...result, conversationId: autoCreatedConversationId } : result;
1766
1897
  }
@@ -1828,6 +1959,7 @@ var _AIService = class _AIService {
1828
1959
  role: "assistant",
1829
1960
  content: finalResult.content
1830
1961
  });
1962
+ void this.summarizeConversation(conversationId);
1831
1963
  }
1832
1964
  return autoCreatedConversationId ? { ...finalResult, conversationId: autoCreatedConversationId } : finalResult;
1833
1965
  }
@@ -1886,6 +2018,7 @@ var _AIService = class _AIService {
1886
2018
  role: "assistant",
1887
2019
  content: result2.content
1888
2020
  });
2021
+ void this.summarizeConversation(conversationId);
1889
2022
  }
1890
2023
  yield textDeltaPart("stream", result2.content);
1891
2024
  yield finishPart(result2);
@@ -1951,6 +2084,7 @@ var _AIService = class _AIService {
1951
2084
  role: "assistant",
1952
2085
  content: result.content
1953
2086
  });
2087
+ void this.summarizeConversation(conversationId);
1954
2088
  }
1955
2089
  yield textDeltaPart("stream", result.content);
1956
2090
  yield finishPart(result);
@@ -3437,6 +3571,19 @@ var AiConversationObject = import_data.ObjectSchema.create({
3437
3571
  icon: "message-square",
3438
3572
  isSystem: true,
3439
3573
  description: "Persistent AI conversation metadata",
3574
+ // Enable Notion / Figma-style "anyone with the link" sharing.
3575
+ // The platform's plugin-sharing service exposes the share-link UI
3576
+ // and REST surface as soon as this flag is set; no further wiring
3577
+ // is needed in service-ai. `metadata` is redacted so internal
3578
+ // tracking payloads (model token counts, source app context) do not
3579
+ // leak into public shares.
3580
+ publicSharing: {
3581
+ enabled: true,
3582
+ allowedAudiences: ["link_only", "signed_in"],
3583
+ allowedPermissions: ["view"],
3584
+ maxExpiryDays: 90,
3585
+ redactFields: ["metadata"]
3586
+ },
3440
3587
  fields: {
3441
3588
  id: import_data.Field.text({
3442
3589
  label: "Conversation ID",
@@ -5626,9 +5773,9 @@ var AIServicePlugin = class {
5626
5773
  }
5627
5774
  }
5628
5775
  const providerSpecs = {
5629
- openai: { pkg: "@ai-sdk/openai", factory: "openai", defaultModel: "gpt-4o", displayName: "OpenAI" },
5630
- anthropic: { pkg: "@ai-sdk/anthropic", factory: "anthropic", defaultModel: "claude-sonnet-4-20250514", displayName: "Anthropic" },
5631
- google: { pkg: "@ai-sdk/google", factory: "google", defaultModel: "gemini-2.0-flash", displayName: "Google" }
5776
+ openai: { pkg: "@ai-sdk/openai", factory: "openai", createFactory: "createOpenAI", defaultModel: "gpt-4o", displayName: "OpenAI" },
5777
+ anthropic: { pkg: "@ai-sdk/anthropic", factory: "anthropic", createFactory: "createAnthropic", defaultModel: "claude-sonnet-4-20250514", displayName: "Anthropic" },
5778
+ google: { pkg: "@ai-sdk/google", factory: "google", createFactory: "createGoogleGenerativeAI", defaultModel: "gemini-2.0-flash", displayName: "Google" }
5632
5779
  };
5633
5780
  const spec = providerSpecs[provider];
5634
5781
  if (!spec) return null;
@@ -5641,20 +5788,30 @@ var AIServicePlugin = class {
5641
5788
  if (!apiKey) return null;
5642
5789
  const envKey = provider === "openai" ? "OPENAI_API_KEY" : provider === "anthropic" ? "ANTHROPIC_API_KEY" : "GOOGLE_GENERATIVE_AI_API_KEY";
5643
5790
  process.env[envKey] = apiKey;
5791
+ const baseUrl = String(values[`${provider}_base_url`] ?? "").trim() || void 0;
5644
5792
  try {
5645
5793
  const mod = await import(
5646
5794
  /* webpackIgnore: true */
5647
5795
  spec.pkg
5648
5796
  );
5649
- const factory = mod[spec.factory] ?? mod.default;
5797
+ let factory = mod[spec.factory] ?? mod.default;
5798
+ if (baseUrl) {
5799
+ const createFn = mod[spec.createFactory];
5800
+ if (typeof createFn === "function") {
5801
+ factory = createFn({ apiKey, baseURL: baseUrl });
5802
+ } else {
5803
+ ctx.logger.warn(`[AI] ${spec.pkg} has no ${spec.createFactory}; baseURL override ignored.`);
5804
+ }
5805
+ }
5650
5806
  if (typeof factory !== "function") return null;
5651
5807
  const modelId = String(values[`${provider}_model`] ?? "").trim() || spec.defaultModel;
5652
5808
  const useChatApi = provider === "openai" && typeof factory.chat === "function";
5653
5809
  const model = useChatApi ? factory.chat(modelId) : factory(modelId);
5654
5810
  const apiSuffix = useChatApi ? " [chat-completions]" : "";
5811
+ const baseSuffix = baseUrl ? ` @ ${baseUrl}` : "";
5655
5812
  return {
5656
5813
  adapter: new VercelLLMAdapter({ model }),
5657
- description: `${spec.displayName} (model: ${modelId})${apiSuffix}`
5814
+ description: `${spec.displayName} (model: ${modelId})${apiSuffix}${baseSuffix}`
5658
5815
  };
5659
5816
  } catch (err) {
5660
5817
  ctx.logger.warn(
@@ -6183,7 +6340,14 @@ var AIServicePlugin = class {
6183
6340
  for (const [k, v] of Object.entries(payload.values)) {
6184
6341
  values[k] = v?.value;
6185
6342
  }
6186
- const provider = String(values.provider ?? "memory");
6343
+ const providerForTitles = String(values.provider ?? "memory");
6344
+ const titleEnabled = providerForTitles !== "memory" && values.title_generation_enabled !== false;
6345
+ const titleMaxLen = typeof values.title_max_length === "number" ? values.title_max_length : 16;
6346
+ this.service.setTitleGenerationConfig({
6347
+ enabled: titleEnabled,
6348
+ maxLength: titleMaxLen
6349
+ });
6350
+ const provider = providerForTitles;
6187
6351
  if (provider === "memory") return;
6188
6352
  const built = await this.buildAdapterFromValues(ctx, values);
6189
6353
  if (!built) {