@4djs/assistant 0.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/README.md +322 -0
- package/package.json +41 -0
- package/src/core/chat-activity.ts +107 -0
- package/src/core/chat-commands.ts +173 -0
- package/src/core/chat-history.ts +113 -0
- package/src/core/chat-reply-suggestions-parse.ts +119 -0
- package/src/core/code-highlight.ts +20 -0
- package/src/core/create-assistant-store.ts +639 -0
- package/src/core/fetch-suggested-prompts.ts +53 -0
- package/src/core/index.ts +125 -0
- package/src/core/interactive-tools/choices.ts +155 -0
- package/src/core/interactive-tools/confirmation.ts +63 -0
- package/src/core/interactive-tools/constants.ts +22 -0
- package/src/core/interactive-tools/execute.ts +70 -0
- package/src/core/interactive-tools/index.ts +41 -0
- package/src/core/interactive-tools/suggestions.ts +87 -0
- package/src/core/interactive-tools/waiters.ts +55 -0
- package/src/core/llm-chat.ts +686 -0
- package/src/core/llm-config.ts +101 -0
- package/src/core/llm-models.ts +96 -0
- package/src/core/llm-provider.ts +99 -0
- package/src/core/llm-settings-storage.ts +331 -0
- package/src/core/llm-sse.ts +166 -0
- package/src/core/llm-types.ts +52 -0
- package/src/core/markdown-utils.ts +11 -0
- package/src/core/prepare-markdown.ts +38 -0
- package/src/core/types.ts +86 -0
- package/src/css.d.ts +1 -0
- package/src/react/Assistant.tsx +358 -0
- package/src/react/components/HighlightedJsonCode.tsx +24 -0
- package/src/react/components/MarkdownContent.tsx +98 -0
- package/src/react/components/MarkdownEditor.tsx +60 -0
- package/src/react/components/MermaidDiagram.tsx +139 -0
- package/src/react/components/ModelSelector.tsx +243 -0
- package/src/react/components/chat/AssistantErrorCallout.tsx +79 -0
- package/src/react/components/chat/ChatActivity.tsx +274 -0
- package/src/react/components/chat/ChatComposer.tsx +189 -0
- package/src/react/components/chat/ChatEmptyState.tsx +145 -0
- package/src/react/components/chat/ChatInteractivePrompt/choices-prompt.tsx +262 -0
- package/src/react/components/chat/ChatInteractivePrompt/confirmation-prompt.tsx +97 -0
- package/src/react/components/chat/ChatInteractivePrompt/index.tsx +60 -0
- package/src/react/components/chat/ChatInteractivePrompt/shell.tsx +60 -0
- package/src/react/components/chat/ChatInteractivePrompt/utils.ts +14 -0
- package/src/react/components/chat/ChatMessage.tsx +150 -0
- package/src/react/components/chat/ChatMessageScroll.tsx +116 -0
- package/src/react/components/chat/ChatReplySuggestions.tsx +231 -0
- package/src/react/components/chat/ComposerCommandMenu.tsx +69 -0
- package/src/react/components/chat/LlmSettingsStrip.tsx +348 -0
- package/src/react/components/chat/LlmSetupPrompt.tsx +58 -0
- package/src/react/components/chat/LlmUnavailableBanner.tsx +11 -0
- package/src/react/components/chat/SuggestedPromptsList.tsx +121 -0
- package/src/react/components/chat/SuggestedPromptsStrip.tsx +72 -0
- package/src/react/components/chat/SystemPromptField.tsx +107 -0
- package/src/react/components/highlighted-code.tsx +107 -0
- package/src/react/context.tsx +72 -0
- package/src/react/hooks/use-composer-commands.ts +129 -0
- package/src/react/hooks/use-suggested-prompts.ts +128 -0
- package/src/react/index.ts +39 -0
- package/src/react/lib/parse-assistant-error.ts +96 -0
- package/src/react/lib/prompt-icons.ts +40 -0
- package/src/react/types.ts +83 -0
- package/src/react/utils/cn.ts +5 -0
- package/src/styles/assistant.css +3009 -0
- package/test/buildLlmHistory.test.ts +95 -0
- package/test/llm-config.test.ts +72 -0
- package/test/llmSettingsStorage.test.ts +121 -0
- package/test/parse-assistant-error.test.ts +24 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { buildLlmHistory, sanitizeLlmMessages } from "../src/core/llm-chat.ts";
|
|
3
|
+
import type { LlmChatMessage } from "../src/core/llm-types.ts";
|
|
4
|
+
|
|
5
|
+
test("buildLlmHistory keeps assistant tool_calls grouped with tool results when trimming", () => {
|
|
6
|
+
const messages = Array.from({ length: 15 }, (_, index) => ({
|
|
7
|
+
role: "user" as const,
|
|
8
|
+
content: `question ${index}`,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
messages.push({
|
|
12
|
+
role: "assistant",
|
|
13
|
+
content: "Found users",
|
|
14
|
+
activity: [
|
|
15
|
+
{
|
|
16
|
+
name: "query_entities",
|
|
17
|
+
args: { dataClass: "User" },
|
|
18
|
+
result: { __COUNT: 1 },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "suggest_replies",
|
|
22
|
+
args: { question: "Next?", suggestions: ["A", "B"] },
|
|
23
|
+
result: { displayed: true },
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const history = buildLlmHistory(messages);
|
|
29
|
+
expect(history[0]?.role).not.toBe("tool");
|
|
30
|
+
expect(history.some((message) => message.role === "tool")).toBe(true);
|
|
31
|
+
|
|
32
|
+
const assistantWithTools = history.find(
|
|
33
|
+
(message) => message.role === "assistant" && message.tool_calls?.length,
|
|
34
|
+
);
|
|
35
|
+
expect(assistantWithTools).toBeDefined();
|
|
36
|
+
for (const toolCall of assistantWithTools?.tool_calls ?? []) {
|
|
37
|
+
expect(
|
|
38
|
+
history.some(
|
|
39
|
+
(message) =>
|
|
40
|
+
message.role === "tool" && message.tool_call_id === toolCall.id,
|
|
41
|
+
),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("sanitizeLlmMessages drops orphan tool messages", () => {
|
|
47
|
+
const messages: LlmChatMessage[] = [
|
|
48
|
+
{
|
|
49
|
+
role: "tool",
|
|
50
|
+
tool_call_id: "call_orphan",
|
|
51
|
+
content: "{}",
|
|
52
|
+
},
|
|
53
|
+
{ role: "user", content: "hello" },
|
|
54
|
+
{
|
|
55
|
+
role: "assistant",
|
|
56
|
+
content: null,
|
|
57
|
+
tool_calls: [
|
|
58
|
+
{
|
|
59
|
+
id: "call_ok_0",
|
|
60
|
+
type: "function",
|
|
61
|
+
function: { name: "query_entities", arguments: "{}" },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "call_ok_1",
|
|
65
|
+
type: "function",
|
|
66
|
+
function: { name: "suggest_replies", arguments: "{}" },
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{ role: "tool", tool_call_id: "call_ok_0", content: "{}" },
|
|
71
|
+
{ role: "tool", tool_call_id: "call_ok_1", content: "{}" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
expect(sanitizeLlmMessages(messages)).toEqual([
|
|
75
|
+
{ role: "user", content: "hello" },
|
|
76
|
+
{
|
|
77
|
+
role: "assistant",
|
|
78
|
+
content: null,
|
|
79
|
+
tool_calls: [
|
|
80
|
+
{
|
|
81
|
+
id: "call_ok_0",
|
|
82
|
+
type: "function",
|
|
83
|
+
function: { name: "query_entities", arguments: "{}" },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "call_ok_1",
|
|
87
|
+
type: "function",
|
|
88
|
+
function: { name: "suggest_replies", arguments: "{}" },
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{ role: "tool", tool_call_id: "call_ok_0", content: "{}" },
|
|
93
|
+
{ role: "tool", tool_call_id: "call_ok_1", content: "{}" },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isLlmConfigured,
|
|
4
|
+
isLlmUnavailableMessage,
|
|
5
|
+
isLocalLlmBaseUrl,
|
|
6
|
+
} from "../src/core/llm-config.ts";
|
|
7
|
+
|
|
8
|
+
describe("isLocalLlmBaseUrl", () => {
|
|
9
|
+
test("detects localhost providers", () => {
|
|
10
|
+
expect(isLocalLlmBaseUrl("http://127.0.0.1:11434/v1")).toBe(true);
|
|
11
|
+
expect(isLocalLlmBaseUrl("http://localhost:1234/v1")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("rejects remote providers", () => {
|
|
15
|
+
expect(isLocalLlmBaseUrl("https://api.openai.com/v1")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("isLlmConfigured", () => {
|
|
20
|
+
test("requires an api key for remote providers", () => {
|
|
21
|
+
expect(
|
|
22
|
+
isLlmConfigured({
|
|
23
|
+
enabled: true,
|
|
24
|
+
baseUrl: "https://api.openai.com/v1",
|
|
25
|
+
apiKey: null,
|
|
26
|
+
model: "gpt-4o-mini",
|
|
27
|
+
}),
|
|
28
|
+
).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("allows local providers without an api key", () => {
|
|
32
|
+
expect(
|
|
33
|
+
isLlmConfigured({
|
|
34
|
+
enabled: true,
|
|
35
|
+
baseUrl: "http://127.0.0.1:11434/v1",
|
|
36
|
+
apiKey: null,
|
|
37
|
+
model: "llama3.2",
|
|
38
|
+
}),
|
|
39
|
+
).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("accepts remote providers with an api key", () => {
|
|
43
|
+
expect(
|
|
44
|
+
isLlmConfigured({
|
|
45
|
+
enabled: true,
|
|
46
|
+
baseUrl: "https://api.openai.com/v1",
|
|
47
|
+
apiKey: "sk-test",
|
|
48
|
+
model: "gpt-4o-mini",
|
|
49
|
+
}),
|
|
50
|
+
).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("isLlmUnavailableMessage", () => {
|
|
55
|
+
test("detects explicit setup flag", () => {
|
|
56
|
+
expect(
|
|
57
|
+
isLlmUnavailableMessage({
|
|
58
|
+
content: "anything",
|
|
59
|
+
llmSetupRequired: true,
|
|
60
|
+
}),
|
|
61
|
+
).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("detects legacy plain-text content", () => {
|
|
65
|
+
expect(
|
|
66
|
+
isLlmUnavailableMessage({
|
|
67
|
+
content:
|
|
68
|
+
"Chat requires an LLM. Open LLM settings to add your provider base URL, model, and API key (optional for local servers).",
|
|
69
|
+
}),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
assistantToStored,
|
|
4
|
+
createDefaultStoredSettings,
|
|
5
|
+
isLlmSettingsFormDirty,
|
|
6
|
+
mergeLlmSettings,
|
|
7
|
+
normalizeStoredSettings,
|
|
8
|
+
normalizeStoredSystemPrompt,
|
|
9
|
+
resolveSelectedModel,
|
|
10
|
+
storedSettingsHaveOverrides,
|
|
11
|
+
storedToAssistant,
|
|
12
|
+
toStoredSettings,
|
|
13
|
+
} from "../src/core/llm-settings-storage.ts";
|
|
14
|
+
|
|
15
|
+
const base = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
baseUrl: "https://api.openai.com/v1",
|
|
18
|
+
apiKey: "env-key",
|
|
19
|
+
model: "gpt-4o-mini",
|
|
20
|
+
models: ["gpt-4o-mini", "gpt-4o"],
|
|
21
|
+
systemPrompt: "Default prompt",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test("toStoredSettings persists all fields including empty system prompt", () => {
|
|
25
|
+
const stored = toStoredSettings(
|
|
26
|
+
{
|
|
27
|
+
baseUrl: "https://custom.example/v1",
|
|
28
|
+
apiKey: "",
|
|
29
|
+
model: "gpt-4o",
|
|
30
|
+
modelsText: "gpt-4o, gpt-4o-mini",
|
|
31
|
+
systemPrompt: "",
|
|
32
|
+
},
|
|
33
|
+
"existing-key",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(stored).toEqual({
|
|
37
|
+
baseUrl: "https://custom.example/v1",
|
|
38
|
+
apiKey: "existing-key",
|
|
39
|
+
model: "gpt-4o",
|
|
40
|
+
models: ["gpt-4o", "gpt-4o-mini"],
|
|
41
|
+
systemPrompt: "",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("stored settings round-trip through merge", () => {
|
|
46
|
+
const stored = assistantToStored(base);
|
|
47
|
+
const merged = mergeLlmSettings(
|
|
48
|
+
{ enabled: false, baseUrl: "x", apiKey: null, model: "y" },
|
|
49
|
+
stored,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(merged).toEqual(storedToAssistant(stored));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("normalizeStoredSettings fills missing legacy fields from base", () => {
|
|
56
|
+
const normalized = normalizeStoredSettings({ model: "gpt-4.1" }, base);
|
|
57
|
+
|
|
58
|
+
expect(normalized?.model).toBe("gpt-4.1");
|
|
59
|
+
expect(normalized?.baseUrl).toBe(base.baseUrl);
|
|
60
|
+
expect(normalized?.apiKey).toBe(base.apiKey);
|
|
61
|
+
expect(normalized?.systemPrompt).toBe(base.systemPrompt);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("empty stored system prompt falls back to base default", () => {
|
|
65
|
+
const stored = createDefaultStoredSettings(base);
|
|
66
|
+
const merged = mergeLlmSettings(base, stored);
|
|
67
|
+
|
|
68
|
+
expect(merged.systemPrompt).toBe(base.systemPrompt);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("storedSettingsHaveOverrides ignores default system prompt content", () => {
|
|
72
|
+
const defaults = createDefaultStoredSettings(base);
|
|
73
|
+
expect(storedSettingsHaveOverrides(defaults, base)).toBe(false);
|
|
74
|
+
expect(
|
|
75
|
+
storedSettingsHaveOverrides(
|
|
76
|
+
{ ...defaults, systemPrompt: base.systemPrompt ?? "" },
|
|
77
|
+
base,
|
|
78
|
+
),
|
|
79
|
+
).toBe(false);
|
|
80
|
+
expect(
|
|
81
|
+
storedSettingsHaveOverrides(
|
|
82
|
+
{ ...defaults, systemPrompt: "Custom prompt" },
|
|
83
|
+
base,
|
|
84
|
+
),
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("normalizeStoredSystemPrompt collapses base prompt to empty", () => {
|
|
89
|
+
expect(normalizeStoredSystemPrompt("", base)).toBe("");
|
|
90
|
+
expect(normalizeStoredSystemPrompt("Default prompt", base)).toBe("");
|
|
91
|
+
expect(normalizeStoredSystemPrompt("Custom prompt", base)).toBe(
|
|
92
|
+
"Custom prompt",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("isLlmSettingsFormDirty detects unsaved form edits", () => {
|
|
97
|
+
const saved = {
|
|
98
|
+
baseUrl: "https://api.openai.com/v1",
|
|
99
|
+
apiKey: "",
|
|
100
|
+
model: "gpt-4o-mini",
|
|
101
|
+
modelsText: "",
|
|
102
|
+
systemPrompt: "",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
expect(isLlmSettingsFormDirty(saved, saved)).toBe(false);
|
|
106
|
+
expect(isLlmSettingsFormDirty({ ...saved, model: "gpt-4o" }, saved)).toBe(
|
|
107
|
+
true,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("resolveSelectedModel keeps stored custom models", () => {
|
|
112
|
+
expect(
|
|
113
|
+
resolveSelectedModel("gpt-4o", "gpt-4o-mini", ["gpt-4o-mini", "gpt-4o"]),
|
|
114
|
+
).toBe("gpt-4o");
|
|
115
|
+
expect(
|
|
116
|
+
resolveSelectedModel("gpt-5.5", "gpt-4o-mini", ["gpt-4o-mini", "gpt-4o"]),
|
|
117
|
+
).toBe("gpt-5.5");
|
|
118
|
+
expect(
|
|
119
|
+
resolveSelectedModel("", "gpt-4o-mini", ["gpt-4o-mini", "gpt-4o"]),
|
|
120
|
+
).toBe("gpt-4o-mini");
|
|
121
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseAssistantError } from "../src/react/lib/parse-assistant-error.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseAssistantError", () => {
|
|
5
|
+
test("strips chat prefix and maps fetch failures to network", () => {
|
|
6
|
+
const parsed = parseAssistantError("Chat failed: Failed to fetch");
|
|
7
|
+
expect(parsed.title).toBe("Connection lost");
|
|
8
|
+
expect(parsed.detail).toBe("Failed to fetch");
|
|
9
|
+
expect(parsed.kind).toBe("network");
|
|
10
|
+
expect(parsed.hint).toContain("LLM endpoint");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("maps auth errors", () => {
|
|
14
|
+
const parsed = parseAssistantError("401 Unauthorized");
|
|
15
|
+
expect(parsed.title).toBe("Authentication failed");
|
|
16
|
+
expect(parsed.kind).toBe("auth");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("uses suggestions context copy", () => {
|
|
20
|
+
const parsed = parseAssistantError("Failed to fetch", "suggestions");
|
|
21
|
+
expect(parsed.title).toBe("Connection lost");
|
|
22
|
+
expect(parsed.hint).toContain("LLM settings");
|
|
23
|
+
});
|
|
24
|
+
});
|