@nexalink/carecompass-sdk 0.3.0-beta.15 → 0.4.0-beta.15
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 +203 -131
- package/dist/cli.js +185 -32
- package/dist/component.d.ts +10 -2
- package/dist/component.js +59 -57
- package/dist/core.d.ts +78 -0
- package/dist/core.js +220 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/markdown.d.ts +3 -0
- package/dist/markdown.js +183 -0
- package/dist/ui.d.ts +3 -0
- package/dist/ui.js +2 -0
- package/dist/version.js +1 -1
- package/package.json +17 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { careCompassApiBaseUrlSchema, createDefaultCareCompassConfig, validateCareCompassConfig, } from "./schema.js";
|
|
@@ -32,13 +32,26 @@ planning, emergency triage, legal advice, and coverage guarantees. When a member
|
|
|
32
32
|
needs clinical, emergency, plan-specific, or account-specific help that requires
|
|
33
33
|
a human, route them to the customer's approved support resources.`;
|
|
34
34
|
const CLI_COMMAND_ALLOWLIST = new Set([
|
|
35
|
+
"auth",
|
|
36
|
+
"completion",
|
|
37
|
+
"config",
|
|
38
|
+
"configure",
|
|
35
39
|
"doctor",
|
|
40
|
+
"embed",
|
|
41
|
+
"health",
|
|
36
42
|
"help",
|
|
37
43
|
"init",
|
|
38
44
|
"login",
|
|
45
|
+
"logout",
|
|
46
|
+
"query",
|
|
47
|
+
"setup",
|
|
39
48
|
"snippet",
|
|
40
49
|
"validate",
|
|
50
|
+
"version",
|
|
41
51
|
]);
|
|
52
|
+
const UNAUTHENTICATED_AUTH_ATTEMPT_LIMIT = 2;
|
|
53
|
+
const UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
54
|
+
const UNAUTHENTICATED_AUTH_RATE_LIMIT_FILE = "auth-rate-limit.json";
|
|
42
55
|
class CliTelemetryError extends Error {
|
|
43
56
|
constructor(message, telemetry) {
|
|
44
57
|
super(message);
|
|
@@ -47,6 +60,8 @@ class CliTelemetryError extends Error {
|
|
|
47
60
|
}
|
|
48
61
|
const sanitizeCommandName = (command) => {
|
|
49
62
|
const normalized = command === "--help" || command === "-h" ? "help" : command;
|
|
63
|
+
if (command === "--version" || command === "-v")
|
|
64
|
+
return "version";
|
|
50
65
|
return CLI_COMMAND_ALLOWLIST.has(normalized) ? normalized : "unknown_command";
|
|
51
66
|
};
|
|
52
67
|
const authTelemetryForSource = (source) => {
|
|
@@ -58,6 +73,96 @@ const authTelemetryForSource = (source) => {
|
|
|
58
73
|
}
|
|
59
74
|
return { authMethod: "api_key", keySource: "cli_flag" };
|
|
60
75
|
};
|
|
76
|
+
const unauthenticatedRateLimitPath = (io) => io.env.CARECOMPASS_AUTH_RATE_LIMIT_PATH ??
|
|
77
|
+
join(dirname(resolveCredentialsPath(io.env)), UNAUTHENTICATED_AUTH_RATE_LIMIT_FILE);
|
|
78
|
+
const unauthenticatedRateLimitKey = (io) => {
|
|
79
|
+
const keyMaterial = io.env.CARECOMPASS_AUTH_RATE_LIMIT_KEY
|
|
80
|
+
? `explicit:${io.env.CARECOMPASS_AUTH_RATE_LIMIT_KEY}`
|
|
81
|
+
: io.env.CARECOMPASS_CLIENT_IP
|
|
82
|
+
? `ip:${io.env.CARECOMPASS_CLIENT_IP}`
|
|
83
|
+
: `credentials:${resolveCredentialsPath(io.env)}`;
|
|
84
|
+
return createHash("sha256")
|
|
85
|
+
.update("carecompass unauthenticated auth rate limit")
|
|
86
|
+
.update(keyMaterial)
|
|
87
|
+
.digest("hex");
|
|
88
|
+
};
|
|
89
|
+
const emptyUnauthenticatedAuthLedger = () => ({
|
|
90
|
+
version: 1,
|
|
91
|
+
entries: {},
|
|
92
|
+
});
|
|
93
|
+
const readUnauthenticatedAuthLedger = (path) => {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
96
|
+
if (parsed.version !== 1 || typeof parsed.entries !== "object") {
|
|
97
|
+
return emptyUnauthenticatedAuthLedger();
|
|
98
|
+
}
|
|
99
|
+
const entries = {};
|
|
100
|
+
for (const [key, value] of Object.entries(parsed.entries)) {
|
|
101
|
+
if (typeof value === "object" &&
|
|
102
|
+
value !== null &&
|
|
103
|
+
typeof value.failures ===
|
|
104
|
+
"number" &&
|
|
105
|
+
typeof value.firstAttemptAt ===
|
|
106
|
+
"number" &&
|
|
107
|
+
typeof value.lastAttemptAt ===
|
|
108
|
+
"number") {
|
|
109
|
+
entries[key] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { version: 1, entries };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return emptyUnauthenticatedAuthLedger();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const writeUnauthenticatedAuthLedger = (path, ledger) => {
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
120
|
+
writeFileSync(path, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
|
|
121
|
+
try {
|
|
122
|
+
chmodSync(path, 0o600);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Some filesystems do not support chmod; the ledger contains only hashed
|
|
126
|
+
// limiter keys and counters.
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const pruneUnauthenticatedAuthLedger = (ledger, now) => {
|
|
130
|
+
for (const [key, entry] of Object.entries(ledger.entries)) {
|
|
131
|
+
if (now - entry.firstAttemptAt >
|
|
132
|
+
UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS) {
|
|
133
|
+
delete ledger.entries[key];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const recordUnauthenticatedAuthFailure = (io) => {
|
|
138
|
+
const path = unauthenticatedRateLimitPath(io);
|
|
139
|
+
const key = unauthenticatedRateLimitKey(io);
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const ledger = readUnauthenticatedAuthLedger(path);
|
|
142
|
+
pruneUnauthenticatedAuthLedger(ledger, now);
|
|
143
|
+
const existing = ledger.entries[key];
|
|
144
|
+
const entry = existing &&
|
|
145
|
+
now - existing.firstAttemptAt <= UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS
|
|
146
|
+
? existing
|
|
147
|
+
: { failures: 0, firstAttemptAt: now, lastAttemptAt: now };
|
|
148
|
+
entry.failures += 1;
|
|
149
|
+
entry.lastAttemptAt = now;
|
|
150
|
+
ledger.entries[key] = entry;
|
|
151
|
+
writeUnauthenticatedAuthLedger(path, ledger);
|
|
152
|
+
const failures = entry.failures;
|
|
153
|
+
if (failures < UNAUTHENTICATED_AUTH_ATTEMPT_LIMIT)
|
|
154
|
+
return null;
|
|
155
|
+
return new CliTelemetryError("Too many failed authentication attempts. Wait before trying again or verify the API key in the Nexalink console.", {
|
|
156
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.RATE_LIMIT_EXCEEDED,
|
|
157
|
+
failureCategory: "rate_limit_error",
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
const resetUnauthenticatedAuthFailures = (io) => {
|
|
161
|
+
const path = unauthenticatedRateLimitPath(io);
|
|
162
|
+
const ledger = readUnauthenticatedAuthLedger(path);
|
|
163
|
+
delete ledger.entries[unauthenticatedRateLimitKey(io)];
|
|
164
|
+
writeUnauthenticatedAuthLedger(path, ledger);
|
|
165
|
+
};
|
|
61
166
|
const normalizeBaaStatus = (value) => {
|
|
62
167
|
if (value === "signed" || value === "pending" || value === "not_required") {
|
|
63
168
|
return value;
|
|
@@ -526,14 +631,11 @@ async function resolveInitApiKey(flags, io, apiBaseUrl, onAuthChecked = async ()
|
|
|
526
631
|
failureCategory: validation.failureCategory,
|
|
527
632
|
});
|
|
528
633
|
io.stdout.write(`${validation.message}\n`);
|
|
634
|
+
const rateLimitError = recordUnauthenticatedAuthFailure(io);
|
|
635
|
+
if (rateLimitError)
|
|
636
|
+
throw rateLimitError;
|
|
529
637
|
continue;
|
|
530
638
|
}
|
|
531
|
-
await onAuthChecked({
|
|
532
|
-
authResult: "success",
|
|
533
|
-
baaStatus: validation.baaStatus,
|
|
534
|
-
keySource: authTelemetry.keySource,
|
|
535
|
-
failureCategory: null,
|
|
536
|
-
});
|
|
537
639
|
if (validation.accountType !== accountType) {
|
|
538
640
|
if (validation.accountType === "enterprise") {
|
|
539
641
|
io.stdout.write(`This API key belongs to an enterprise account. ${enterpriseUnavailableMessage(urls.developer)}\n`);
|
|
@@ -543,12 +645,14 @@ async function resolveInitApiKey(flags, io, apiBaseUrl, onAuthChecked = async ()
|
|
|
543
645
|
continue;
|
|
544
646
|
}
|
|
545
647
|
writeStoredApiKey(normalizedApiKey, credentialsPath);
|
|
648
|
+
resetUnauthenticatedAuthFailures(io);
|
|
546
649
|
io.stdout.write(`Stored CareCompass credentials at ${credentialsPath}\n`);
|
|
547
650
|
io.stdout.write("Sign-in successful.\n");
|
|
548
651
|
return {
|
|
549
652
|
apiKey: normalizedApiKey,
|
|
550
653
|
authMethod: authTelemetry.authMethod,
|
|
551
654
|
baaStatus: validation.baaStatus,
|
|
655
|
+
keySource: authTelemetry.keySource,
|
|
552
656
|
setup: validation.setup,
|
|
553
657
|
};
|
|
554
658
|
}
|
|
@@ -790,12 +894,14 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
790
894
|
const sessionStartedAt = Date.now();
|
|
791
895
|
const rawCommand = argv[0] ?? "help";
|
|
792
896
|
const telemetryCommandName = sanitizeCommandName(rawCommand);
|
|
793
|
-
|
|
897
|
+
let commandStartedAt = sessionStartedAt;
|
|
898
|
+
let commandStartedCaptured = false;
|
|
794
899
|
let commandsAttempted = 0;
|
|
795
900
|
let commandsSucceeded = 0;
|
|
796
901
|
let commandsFailed = 0;
|
|
797
902
|
let activeCommand = rawCommand;
|
|
798
903
|
let authCheckedCaptured = false;
|
|
904
|
+
let initStartedCaptured = false;
|
|
799
905
|
let sessionEndedCaptured = false;
|
|
800
906
|
const captureAuthChecked = async (telemetry) => {
|
|
801
907
|
if (authCheckedCaptured)
|
|
@@ -811,7 +917,19 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
811
917
|
keySource: telemetry.keySource,
|
|
812
918
|
});
|
|
813
919
|
};
|
|
920
|
+
const captureCommandStarted = async () => {
|
|
921
|
+
if (commandStartedCaptured)
|
|
922
|
+
return;
|
|
923
|
+
commandStartedCaptured = true;
|
|
924
|
+
commandStartedAt = Date.now();
|
|
925
|
+
commandsAttempted += 1;
|
|
926
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_STARTED, {
|
|
927
|
+
commandName: telemetryCommandName,
|
|
928
|
+
commandArgsCount: Math.max(argv.length - 1, 0),
|
|
929
|
+
});
|
|
930
|
+
};
|
|
814
931
|
const captureCommandCompleted = async () => {
|
|
932
|
+
await captureCommandStarted();
|
|
815
933
|
commandsSucceeded += 1;
|
|
816
934
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_COMPLETED, {
|
|
817
935
|
commandName: telemetryCommandName,
|
|
@@ -820,6 +938,34 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
820
938
|
modelId: null,
|
|
821
939
|
});
|
|
822
940
|
};
|
|
941
|
+
const authenticateConfigCommand = async (flags) => {
|
|
942
|
+
let credentials;
|
|
943
|
+
try {
|
|
944
|
+
credentials = requireApiKey(flags, io);
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
const failure = classifyCliFailure(error);
|
|
948
|
+
await captureAuthChecked({
|
|
949
|
+
authResult: "failure",
|
|
950
|
+
baaStatus: "signed",
|
|
951
|
+
keySource: "cli_flag",
|
|
952
|
+
errorCode: failure.errorCode,
|
|
953
|
+
failureCategory: failure.failureCategory,
|
|
954
|
+
});
|
|
955
|
+
const rateLimitError = recordUnauthenticatedAuthFailure(io);
|
|
956
|
+
throw rateLimitError ?? error;
|
|
957
|
+
}
|
|
958
|
+
const config = readConfig(flagString(flags, "config") ?? DEFAULT_CONFIG_PATH);
|
|
959
|
+
resetUnauthenticatedAuthFailures(io);
|
|
960
|
+
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
961
|
+
await captureAuthChecked({
|
|
962
|
+
authResult: "success",
|
|
963
|
+
baaStatus: "not_required",
|
|
964
|
+
keySource: authTelemetryForSource(credentials.source).keySource,
|
|
965
|
+
failureCategory: null,
|
|
966
|
+
});
|
|
967
|
+
return { config, credentials };
|
|
968
|
+
};
|
|
823
969
|
const captureSessionEnded = async (exitReason) => {
|
|
824
970
|
if (sessionEndedCaptured)
|
|
825
971
|
return;
|
|
@@ -833,11 +979,6 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
833
979
|
});
|
|
834
980
|
};
|
|
835
981
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_STARTED);
|
|
836
|
-
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_STARTED, {
|
|
837
|
-
commandName: telemetryCommandName,
|
|
838
|
-
commandArgsCount: Math.max(argv.length - 1, 0),
|
|
839
|
-
});
|
|
840
|
-
commandsAttempted += 1;
|
|
841
982
|
try {
|
|
842
983
|
const { command, flags, positionals } = parseArgs(argv);
|
|
843
984
|
activeCommand = command;
|
|
@@ -847,6 +988,7 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
847
988
|
return 0;
|
|
848
989
|
}
|
|
849
990
|
if (command === "login") {
|
|
991
|
+
await captureCommandStarted();
|
|
850
992
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_LOGIN_STARTED);
|
|
851
993
|
const apiKey = flagString(flags, "api-key") ?? positionals[0];
|
|
852
994
|
if (!apiKey) {
|
|
@@ -867,12 +1009,22 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
867
1009
|
return 0;
|
|
868
1010
|
}
|
|
869
1011
|
if (command === "init") {
|
|
870
|
-
const initStartedAt = Date.now();
|
|
871
|
-
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_STARTED);
|
|
872
1012
|
rejectLegacyBaaInitFlags(flags, io);
|
|
873
1013
|
const outputDir = flagString(flags, "output-dir") ?? DEFAULT_OUTPUT_DIR;
|
|
874
1014
|
const apiBaseUrl = resolveTrustedApiBaseUrl(flagString(flags, "api-base-url") ?? DEFAULT_API_BASE_URL);
|
|
875
1015
|
const initAuth = await resolveInitApiKey(flags, io, apiBaseUrl, captureAuthChecked);
|
|
1016
|
+
const config = buildInitConfig(flags, initAuth.setup);
|
|
1017
|
+
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
1018
|
+
await captureAuthChecked({
|
|
1019
|
+
authResult: "success",
|
|
1020
|
+
baaStatus: initAuth.baaStatus,
|
|
1021
|
+
keySource: initAuth.keySource,
|
|
1022
|
+
failureCategory: null,
|
|
1023
|
+
});
|
|
1024
|
+
await captureCommandStarted();
|
|
1025
|
+
const initStartedAt = Date.now();
|
|
1026
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_STARTED);
|
|
1027
|
+
initStartedCaptured = true;
|
|
876
1028
|
const output = flagString(flags, "output") ??
|
|
877
1029
|
join(outputDir, "carecompass.config.json");
|
|
878
1030
|
const snippetOutput = flagString(flags, "snippet-output") ??
|
|
@@ -895,8 +1047,6 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
895
1047
|
}
|
|
896
1048
|
}
|
|
897
1049
|
mkdirSync(outputDir, { recursive: true });
|
|
898
|
-
const config = buildInitConfig(flags, initAuth.setup);
|
|
899
|
-
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
900
1050
|
writeFileSync(output, `${JSON.stringify(config, null, 2)}\n`);
|
|
901
1051
|
writeFileSync(promptOutput, `${DEFAULT_SYSTEM_PROMPT}\n`);
|
|
902
1052
|
const resolvedConfig = readConfig(output);
|
|
@@ -946,9 +1096,8 @@ Next command:
|
|
|
946
1096
|
}
|
|
947
1097
|
if (command === "validate") {
|
|
948
1098
|
try {
|
|
949
|
-
const credentials =
|
|
950
|
-
|
|
951
|
-
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
1099
|
+
const { config, credentials } = await authenticateConfigCommand(flags);
|
|
1100
|
+
await captureCommandStarted();
|
|
952
1101
|
if (flags["local-only"] !== true) {
|
|
953
1102
|
await assertHostedConfig(config, credentials.apiKey);
|
|
954
1103
|
}
|
|
@@ -959,17 +1108,18 @@ Next command:
|
|
|
959
1108
|
}
|
|
960
1109
|
catch (error) {
|
|
961
1110
|
const failure = classifyCliFailure(error);
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1111
|
+
if (commandStartedCaptured) {
|
|
1112
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_VALIDATION_FAILED, {
|
|
1113
|
+
errorCode: failure.errorCode,
|
|
1114
|
+
failureCategory: failure.failureCategory,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
966
1117
|
throw error;
|
|
967
1118
|
}
|
|
968
1119
|
}
|
|
969
1120
|
if (command === "snippet") {
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
1121
|
+
const { config } = await authenticateConfigCommand(flags);
|
|
1122
|
+
await captureCommandStarted();
|
|
973
1123
|
const snippet = generateCareCompassSnippet(runtimeConfig(config), {
|
|
974
1124
|
mode: snippetMode(flags),
|
|
975
1125
|
});
|
|
@@ -987,9 +1137,8 @@ Next command:
|
|
|
987
1137
|
return 0;
|
|
988
1138
|
}
|
|
989
1139
|
if (command === "doctor") {
|
|
990
|
-
const credentials =
|
|
991
|
-
|
|
992
|
-
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
1140
|
+
const { config, credentials } = await authenticateConfigCommand(flags);
|
|
1141
|
+
await captureCommandStarted();
|
|
993
1142
|
let hostedStatus = "not checked";
|
|
994
1143
|
try {
|
|
995
1144
|
await assertHostedConfig(config, credentials.apiKey);
|
|
@@ -1013,12 +1162,16 @@ Status: ready for staging verification
|
|
|
1013
1162
|
await captureCommandCompleted();
|
|
1014
1163
|
return 0;
|
|
1015
1164
|
}
|
|
1165
|
+
await captureCommandStarted();
|
|
1016
1166
|
throw new Error(`Unknown command: ${command}`);
|
|
1017
1167
|
}
|
|
1018
1168
|
catch (error) {
|
|
1019
1169
|
commandsFailed += 1;
|
|
1170
|
+
if (!commandStartedCaptured && commandsAttempted === 0) {
|
|
1171
|
+
commandsAttempted = 1;
|
|
1172
|
+
}
|
|
1020
1173
|
const failure = classifyCliFailure(error);
|
|
1021
|
-
if (activeCommand === "init") {
|
|
1174
|
+
if (activeCommand === "init" && initStartedCaptured) {
|
|
1022
1175
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_FAILED, {
|
|
1023
1176
|
errorCode: failure.errorCode,
|
|
1024
1177
|
failureCategory: failure.failureCategory,
|
package/dist/component.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type CareCompassContext, type CareCompassCoreEvent, type CareCompassMessage } from "./core.js";
|
|
1
3
|
import type { CareCompassConfig } from "./schema.js";
|
|
2
|
-
export
|
|
4
|
+
export type CareCompassChatProps = {
|
|
3
5
|
config: CareCompassConfig;
|
|
6
|
+
initialContext?: CareCompassContext;
|
|
7
|
+
onEvent?: (event: CareCompassCoreEvent) => void;
|
|
4
8
|
pollIntervalMs?: number;
|
|
5
|
-
|
|
9
|
+
renderMessage?: (message: CareCompassMessage) => React.ReactNode;
|
|
10
|
+
retryLabel?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function CareCompassChat({ config, initialContext, onEvent, pollIntervalMs, renderMessage, retryLabel, }: CareCompassChatProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export declare const CareCompassEmbed: typeof CareCompassChat;
|
package/dist/component.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { createCareCompassCore, } from "./core.js";
|
|
4
|
+
import { CareCompassMarkdown } from "./markdown.js";
|
|
4
5
|
import { CARECOMPASS_TELEMETRY_ERROR_CODES, CARECOMPASS_TELEMETRY_EVENTS, createCareCompassTelemetry, } from "./telemetry.js";
|
|
5
6
|
// The embed should fail closed inside the host page: show the configured
|
|
6
7
|
// fallback text, capture sanitized telemetry, and avoid crashing the integrator.
|
|
@@ -25,71 +26,56 @@ class CareCompassErrorBoundary extends React.Component {
|
|
|
25
26
|
return this.props.children;
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
|
-
function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
text: config.prompt.welcomeMessage,
|
|
35
|
-
},
|
|
36
|
-
]);
|
|
29
|
+
function CareCompassChatContent({ config, initialContext, onEvent, telemetry, pollIntervalMs = 1500, renderMessage, retryLabel = "Retry", }) {
|
|
30
|
+
const core = React.useMemo(() => createCareCompassCore(config, {
|
|
31
|
+
initialContext,
|
|
32
|
+
pollIntervalMs,
|
|
33
|
+
}), [config, initialContext, pollIntervalMs]);
|
|
34
|
+
const [snapshot, setSnapshot] = React.useState(() => core.getSnapshot());
|
|
37
35
|
const [input, setInput] = React.useState("");
|
|
38
|
-
const [loading, setLoading] = React.useState(false);
|
|
39
|
-
const [error, setError] = React.useState(null);
|
|
40
36
|
React.useEffect(() => {
|
|
41
37
|
telemetry.capture(CARECOMPASS_TELEMETRY_EVENTS.COMPONENT_RENDERED);
|
|
42
38
|
telemetry.capture(CARECOMPASS_TELEMETRY_EVENTS.COMPONENT_OPENED);
|
|
39
|
+
const unsubscribe = core.subscribe(setSnapshot);
|
|
40
|
+
setSnapshot(core.getSnapshot());
|
|
43
41
|
return () => {
|
|
42
|
+
unsubscribe();
|
|
43
|
+
core.dispose();
|
|
44
44
|
telemetry.capture(CARECOMPASS_TELEMETRY_EVENTS.COMPONENT_CLOSED);
|
|
45
45
|
};
|
|
46
|
-
}, [telemetry]);
|
|
46
|
+
}, [core, telemetry]);
|
|
47
47
|
async function sendMessage(event) {
|
|
48
48
|
event.preventDefault();
|
|
49
49
|
const prompt = input.trim();
|
|
50
|
-
if (!prompt || loading)
|
|
50
|
+
if (!prompt || snapshot.loading)
|
|
51
51
|
return;
|
|
52
52
|
setInput("");
|
|
53
|
-
setError(null);
|
|
54
|
-
setLoading(true);
|
|
55
|
-
setMessages((current) => [
|
|
56
|
-
...current,
|
|
57
|
-
{ id: `user-${Date.now()}`, role: "user", text: prompt },
|
|
58
|
-
]);
|
|
59
53
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// The backend returns a job immediately, so the component polls until it
|
|
63
|
-
// sees a terminal state or reaches the same timeout tracked by telemetry.
|
|
64
|
-
while (Date.now() - startedAt < 120_000) {
|
|
65
|
-
const status = await client.status(jobId);
|
|
66
|
-
if (status.status === "complete" && status.result) {
|
|
67
|
-
setMessages((current) => [
|
|
68
|
-
...current,
|
|
69
|
-
{
|
|
70
|
-
id: `assistant-${Date.now()}`,
|
|
71
|
-
role: "assistant",
|
|
72
|
-
text: status.result ?? "",
|
|
73
|
-
},
|
|
74
|
-
]);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
if (status.status === "failed") {
|
|
78
|
-
throw new Error(status.result ?? config.labels.errorMessage);
|
|
79
|
-
}
|
|
80
|
-
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
54
|
+
for await (const coreEvent of core.streamMessage(prompt)) {
|
|
55
|
+
onEvent?.(coreEvent);
|
|
81
56
|
}
|
|
82
|
-
client.recordInferenceTimeout(jobId, Date.now() - startedAt);
|
|
83
|
-
throw new Error("CareCompass request timed out.");
|
|
84
57
|
}
|
|
85
|
-
catch
|
|
86
|
-
|
|
87
|
-
|
|
58
|
+
catch {
|
|
59
|
+
// The core snapshot already carries the user-visible error and retry
|
|
60
|
+
// state. Swallow here so host pages do not see unhandled form errors.
|
|
88
61
|
}
|
|
89
|
-
|
|
90
|
-
|
|
62
|
+
}
|
|
63
|
+
async function retryMessage() {
|
|
64
|
+
try {
|
|
65
|
+
await core.retryLast();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// The core owns the visible retry failure state.
|
|
91
69
|
}
|
|
92
70
|
}
|
|
71
|
+
const messageContent = (message) => {
|
|
72
|
+
if (renderMessage)
|
|
73
|
+
return renderMessage(message);
|
|
74
|
+
if (message.role === "assistant") {
|
|
75
|
+
return _jsx(CareCompassMarkdown, { text: message.text });
|
|
76
|
+
}
|
|
77
|
+
return message.text;
|
|
78
|
+
};
|
|
93
79
|
return (_jsxs("section", { "aria-label": config.labels.title, style: {
|
|
94
80
|
"--carecompass-primary": config.theme.primaryColor,
|
|
95
81
|
"--carecompass-bg": config.theme.backgroundColor,
|
|
@@ -118,7 +104,7 @@ function CareCompassEmbedContent({ config, telemetry, pollIntervalMs = 1500, })
|
|
|
118
104
|
gap: 12,
|
|
119
105
|
overflowY: "auto",
|
|
120
106
|
padding: 16,
|
|
121
|
-
}, children: [messages.map((message) => (_jsx("div", { style: {
|
|
107
|
+
}, children: [snapshot.messages.map((message) => (_jsx("div", { style: {
|
|
122
108
|
alignSelf: message.role === "user" ? "flex-end" : "flex-start",
|
|
123
109
|
background: message.role === "user"
|
|
124
110
|
? "var(--carecompass-primary)"
|
|
@@ -127,14 +113,14 @@ function CareCompassEmbedContent({ config, telemetry, pollIntervalMs = 1500, })
|
|
|
127
113
|
color: message.role === "user" ? "#ffffff" : "inherit",
|
|
128
114
|
maxInlineSize: "82%",
|
|
129
115
|
padding: "10px 12px",
|
|
130
|
-
whiteSpace: "pre-wrap",
|
|
131
|
-
}, children: message
|
|
116
|
+
whiteSpace: message.role === "user" ? "pre-wrap" : "normal",
|
|
117
|
+
}, children: messageContent(message) }, message.id))), snapshot.loading ? (_jsx("div", { style: { fontSize: 13, opacity: 0.72 }, children: "Thinking..." })) : null] }), _jsxs("form", { onSubmit: sendMessage, style: {
|
|
132
118
|
borderTop: "1px solid color-mix(in srgb, var(--carecompass-text) 12%, transparent)",
|
|
133
119
|
display: "flex",
|
|
134
120
|
flexDirection: "column",
|
|
135
121
|
gap: 8,
|
|
136
122
|
padding: 12,
|
|
137
|
-
}, children: [_jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsx("input", { "aria-label": config.labels.placeholder, disabled: loading, onChange: (event) => setInput(event.currentTarget.value), placeholder: config.labels.placeholder, style: {
|
|
123
|
+
}, children: [_jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsx("input", { "aria-label": config.labels.placeholder, disabled: snapshot.loading, onChange: (event) => setInput(event.currentTarget.value), placeholder: config.labels.placeholder, style: {
|
|
138
124
|
border: "1px solid color-mix(in srgb, var(--carecompass-text) 16%, transparent)",
|
|
139
125
|
borderRadius: 8,
|
|
140
126
|
color: "inherit",
|
|
@@ -142,20 +128,36 @@ function CareCompassEmbedContent({ config, telemetry, pollIntervalMs = 1500, })
|
|
|
142
128
|
font: "inherit",
|
|
143
129
|
minInlineSize: 0,
|
|
144
130
|
padding: "10px 12px",
|
|
145
|
-
}, value: input }), _jsx("button", { disabled: loading || !input.trim(), style: {
|
|
131
|
+
}, value: input }), _jsx("button", { disabled: snapshot.loading || !input.trim(), style: {
|
|
146
132
|
background: "var(--carecompass-primary)",
|
|
147
133
|
border: 0,
|
|
148
134
|
borderRadius: 8,
|
|
149
135
|
color: "#ffffff",
|
|
150
|
-
cursor: loading ? "not-allowed" : "pointer",
|
|
136
|
+
cursor: snapshot.loading ? "not-allowed" : "pointer",
|
|
151
137
|
font: "inherit",
|
|
152
138
|
fontWeight: 700,
|
|
153
139
|
padding: "0 14px",
|
|
154
|
-
}, type: "submit", children: config.labels.sendButton })] }), error ? (
|
|
140
|
+
}, type: "submit", children: config.labels.sendButton })] }), snapshot.error ? (_jsxs("div", { role: "alert", style: {
|
|
141
|
+
alignItems: "center",
|
|
142
|
+
color: "#b91c1c",
|
|
143
|
+
display: "flex",
|
|
144
|
+
flexWrap: "wrap",
|
|
145
|
+
fontSize: 12,
|
|
146
|
+
gap: 8,
|
|
147
|
+
}, children: [_jsx("span", { children: snapshot.error.message }), snapshot.canRetry ? (_jsx("button", { onClick: retryMessage, style: {
|
|
148
|
+
background: "transparent",
|
|
149
|
+
border: "1px solid color-mix(in srgb, #b91c1c 40%, transparent)",
|
|
150
|
+
borderRadius: 6,
|
|
151
|
+
color: "#b91c1c",
|
|
152
|
+
cursor: "pointer",
|
|
153
|
+
font: "inherit",
|
|
154
|
+
padding: "4px 8px",
|
|
155
|
+
}, type: "button", children: retryLabel })) : null] })) : null, _jsx("div", { style: { fontSize: 11, opacity: 0.7 }, children: config.labels.disclaimer })] })] }));
|
|
155
156
|
}
|
|
156
|
-
export function
|
|
157
|
+
export function CareCompassChat({ config, initialContext, onEvent, pollIntervalMs = 1500, renderMessage, retryLabel, }) {
|
|
157
158
|
// Telemetry is scoped to the validated public config; no per-message content
|
|
158
159
|
// is passed through this component-level telemetry object.
|
|
159
160
|
const telemetry = React.useMemo(() => createCareCompassTelemetry(config), [config]);
|
|
160
|
-
return (_jsx(CareCompassErrorBoundary, { config: config, telemetry: telemetry, children: _jsx(
|
|
161
|
+
return (_jsx(CareCompassErrorBoundary, { config: config, telemetry: telemetry, children: _jsx(CareCompassChatContent, { config: config, initialContext: initialContext, onEvent: onEvent, pollIntervalMs: pollIntervalMs, renderMessage: renderMessage, retryLabel: retryLabel, telemetry: telemetry }) }));
|
|
161
162
|
}
|
|
163
|
+
export const CareCompassEmbed = CareCompassChat;
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type CareCompassStatusResponse } from "./client.js";
|
|
2
|
+
import type { CareCompassConfig } from "./schema.js";
|
|
3
|
+
type Fetcher = typeof fetch;
|
|
4
|
+
export type CareCompassContext = Record<string, unknown>;
|
|
5
|
+
export type CareCompassMessage = {
|
|
6
|
+
id: string;
|
|
7
|
+
role: "user" | "assistant";
|
|
8
|
+
text: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
status: "complete" | "failed" | "pending";
|
|
11
|
+
};
|
|
12
|
+
export type CareCompassLifecyclePhase = "idle" | "submitting" | "streaming" | "complete" | "error" | "disposed";
|
|
13
|
+
export type CareCompassCoreErrorCode = "request_failed" | "timeout" | "disposed";
|
|
14
|
+
export type CareCompassCoreError = {
|
|
15
|
+
code: CareCompassCoreErrorCode;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
export type CareCompassRetryMetadata = {
|
|
19
|
+
prompt: string;
|
|
20
|
+
context: CareCompassContext;
|
|
21
|
+
attempts: number;
|
|
22
|
+
};
|
|
23
|
+
export type CareCompassCoreSnapshot = {
|
|
24
|
+
messages: CareCompassMessage[];
|
|
25
|
+
phase: CareCompassLifecyclePhase;
|
|
26
|
+
loading: boolean;
|
|
27
|
+
error: CareCompassCoreError | null;
|
|
28
|
+
context: CareCompassContext;
|
|
29
|
+
canRetry: boolean;
|
|
30
|
+
retry: CareCompassRetryMetadata | null;
|
|
31
|
+
activeJobId: string | null;
|
|
32
|
+
};
|
|
33
|
+
export type CareCompassCoreEvent = {
|
|
34
|
+
type: "message_submitted";
|
|
35
|
+
message: CareCompassMessage;
|
|
36
|
+
snapshot: CareCompassCoreSnapshot;
|
|
37
|
+
} | {
|
|
38
|
+
type: "status";
|
|
39
|
+
jobId: string;
|
|
40
|
+
status: CareCompassStatusResponse["status"];
|
|
41
|
+
snapshot: CareCompassCoreSnapshot;
|
|
42
|
+
} | {
|
|
43
|
+
type: "message_completed";
|
|
44
|
+
message: CareCompassMessage;
|
|
45
|
+
snapshot: CareCompassCoreSnapshot;
|
|
46
|
+
} | {
|
|
47
|
+
type: "error";
|
|
48
|
+
error: CareCompassCoreError;
|
|
49
|
+
snapshot: CareCompassCoreSnapshot;
|
|
50
|
+
} | {
|
|
51
|
+
type: "context_updated";
|
|
52
|
+
snapshot: CareCompassCoreSnapshot;
|
|
53
|
+
} | {
|
|
54
|
+
type: "reset";
|
|
55
|
+
snapshot: CareCompassCoreSnapshot;
|
|
56
|
+
};
|
|
57
|
+
export type CareCompassCoreOptions = {
|
|
58
|
+
fetcher?: Fetcher;
|
|
59
|
+
initialContext?: CareCompassContext;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
pollIntervalMs?: number;
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
};
|
|
64
|
+
export type CareCompassSendMessageOptions = {
|
|
65
|
+
context?: CareCompassContext;
|
|
66
|
+
};
|
|
67
|
+
export type CareCompassCore = {
|
|
68
|
+
getSnapshot: () => CareCompassCoreSnapshot;
|
|
69
|
+
subscribe: (listener: (snapshot: CareCompassCoreSnapshot) => void) => () => void;
|
|
70
|
+
setContext: (context: CareCompassContext | ((current: CareCompassContext) => CareCompassContext)) => void;
|
|
71
|
+
sendMessage: (prompt: string, options?: CareCompassSendMessageOptions) => Promise<CareCompassCoreSnapshot>;
|
|
72
|
+
streamMessage: (prompt: string, options?: CareCompassSendMessageOptions) => AsyncIterable<CareCompassCoreEvent>;
|
|
73
|
+
retryLast: () => Promise<CareCompassCoreSnapshot>;
|
|
74
|
+
reset: () => void;
|
|
75
|
+
dispose: () => void;
|
|
76
|
+
};
|
|
77
|
+
export declare function createCareCompassCore(config: CareCompassConfig, options?: CareCompassCoreOptions): CareCompassCore;
|
|
78
|
+
export {};
|