@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 +170 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +115 -3
- package/dist/index.d.ts +115 -3
- package/dist/index.js +170 -6
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
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
|
-
|
|
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
|
|
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) {
|