@coze-arch/cli 0.0.18 → 0.0.19-beta.1

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.
Files changed (104) hide show
  1. package/lib/__templates__/expo/.coze +1 -0
  2. package/lib/__templates__/expo/.cozeproj/scripts/validate.sh +8 -0
  3. package/lib/__templates__/expo/package.json +2 -1
  4. package/lib/__templates__/nextjs/.coze +1 -0
  5. package/lib/__templates__/nextjs/package.json +3 -1
  6. package/lib/__templates__/nextjs/scripts/validate.sh +10 -0
  7. package/lib/__templates__/nuxt-vue/.coze +1 -0
  8. package/lib/__templates__/nuxt-vue/eslint.config.mjs +25 -0
  9. package/lib/__templates__/nuxt-vue/package.json +9 -2
  10. package/lib/__templates__/nuxt-vue/pnpm-lock.yaml +790 -10
  11. package/lib/__templates__/nuxt-vue/scripts/validate.sh +10 -0
  12. package/lib/__templates__/pi-agent/.coze +10 -0
  13. package/lib/__templates__/pi-agent/AGENTS.md +144 -0
  14. package/lib/__templates__/pi-agent/README.md +216 -0
  15. package/lib/__templates__/pi-agent/_gitignore +3 -0
  16. package/lib/__templates__/pi-agent/_npmrc +23 -0
  17. package/lib/__templates__/pi-agent/bin/pi-bot.ts +8 -0
  18. package/lib/__templates__/pi-agent/docs/project-overview.md +374 -0
  19. package/lib/__templates__/pi-agent/docs/user/getting-started.md +47 -0
  20. package/lib/__templates__/pi-agent/package.json +63 -0
  21. package/lib/__templates__/pi-agent/pi-resources/SYSTEM.md +15 -0
  22. package/lib/__templates__/pi-agent/pi-resources/extensions/preference-memory/index.ts +355 -0
  23. package/lib/__templates__/pi-agent/pi-resources/skills/coze-asr/SKILL.md +36 -0
  24. package/lib/__templates__/pi-agent/pi-resources/skills/coze-asr/scripts/asr.mjs +9 -0
  25. package/lib/__templates__/pi-agent/pi-resources/skills/coze-image-gen/SKILL.md +41 -0
  26. package/lib/__templates__/pi-agent/pi-resources/skills/coze-image-gen/scripts/gen.mjs +9 -0
  27. package/lib/__templates__/pi-agent/pi-resources/skills/coze-tts/SKILL.md +85 -0
  28. package/lib/__templates__/pi-agent/pi-resources/skills/coze-tts/scripts/tts.mjs +9 -0
  29. package/lib/__templates__/pi-agent/pi-resources/skills/coze-video-gen/SKILL.md +53 -0
  30. package/lib/__templates__/pi-agent/pi-resources/skills/coze-video-gen/scripts/gen.mjs +9 -0
  31. package/lib/__templates__/pi-agent/pnpm-lock.yaml +8282 -0
  32. package/lib/__templates__/pi-agent/scripts/dev.sh +14 -0
  33. package/lib/__templates__/pi-agent/scripts/prepare.sh +35 -0
  34. package/lib/__templates__/pi-agent/src/agent.ts +363 -0
  35. package/lib/__templates__/pi-agent/src/channels/feishu/index.ts +760 -0
  36. package/lib/__templates__/pi-agent/src/channels/feishu/streaming-card.ts +297 -0
  37. package/lib/__templates__/pi-agent/src/channels/wechat/index.ts +171 -0
  38. package/lib/__templates__/pi-agent/src/cli.ts +117 -0
  39. package/lib/__templates__/pi-agent/src/config.ts +708 -0
  40. package/lib/__templates__/pi-agent/src/core.ts +218 -0
  41. package/lib/__templates__/pi-agent/src/dashboard/api/channels.ts +104 -0
  42. package/lib/__templates__/pi-agent/src/dashboard/api/docs.ts +204 -0
  43. package/lib/__templates__/pi-agent/src/dashboard/api/models.ts +98 -0
  44. package/lib/__templates__/pi-agent/src/dashboard/api/overview.ts +33 -0
  45. package/lib/__templates__/pi-agent/src/dashboard/config-store.ts +64 -0
  46. package/lib/__templates__/pi-agent/src/dashboard/index.ts +39 -0
  47. package/lib/__templates__/pi-agent/src/dashboard/server.ts +622 -0
  48. package/lib/__templates__/pi-agent/src/dashboard/types.ts +25 -0
  49. package/lib/__templates__/pi-agent/src/dashboard/web/index.html +13 -0
  50. package/lib/__templates__/pi-agent/src/dashboard/web/postcss.config.cjs +7 -0
  51. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/app-layout.tsx +186 -0
  52. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/page-title.tsx +17 -0
  53. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/alert.tsx +22 -0
  54. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/badge.tsx +25 -0
  55. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/button.tsx +40 -0
  56. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/card.tsx +29 -0
  57. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/input.tsx +18 -0
  58. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/label.tsx +8 -0
  59. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/select.tsx +80 -0
  60. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/separator.tsx +23 -0
  61. package/lib/__templates__/pi-agent/src/dashboard/web/src/hooks/use-fetch.ts +32 -0
  62. package/lib/__templates__/pi-agent/src/dashboard/web/src/hooks/use-local-storage-state.ts +23 -0
  63. package/lib/__templates__/pi-agent/src/dashboard/web/src/main.tsx +30 -0
  64. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/channels-page.tsx +188 -0
  65. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/chat-page.tsx +451 -0
  66. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/docs-page.tsx +65 -0
  67. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/models-page.tsx +122 -0
  68. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/overview-page.tsx +134 -0
  69. package/lib/__templates__/pi-agent/src/dashboard/web/src/services/chat-ws-service.ts +167 -0
  70. package/lib/__templates__/pi-agent/src/dashboard/web/src/styles.css +294 -0
  71. package/lib/__templates__/pi-agent/src/dashboard/web/src/utils/index.ts +11 -0
  72. package/lib/__templates__/pi-agent/src/dashboard/web/tsconfig.json +13 -0
  73. package/lib/__templates__/pi-agent/src/dashboard/web/vite.config.ts +17 -0
  74. package/lib/__templates__/pi-agent/src/index.ts +123 -0
  75. package/lib/__templates__/pi-agent/src/pi-resources.ts +125 -0
  76. package/lib/__templates__/pi-agent/src/session-store.ts +223 -0
  77. package/lib/__templates__/pi-agent/src/tools/common/format-coze-error.ts +12 -0
  78. package/lib/__templates__/pi-agent/src/tools/index.ts +2 -0
  79. package/lib/__templates__/pi-agent/src/tools/web-fetch/index.ts +195 -0
  80. package/lib/__templates__/pi-agent/src/tools/web-search/index.ts +206 -0
  81. package/lib/__templates__/pi-agent/template.config.js +45 -0
  82. package/lib/__templates__/pi-agent/tests/cli.test.ts +136 -0
  83. package/lib/__templates__/pi-agent/tests/config.test.ts +315 -0
  84. package/lib/__templates__/pi-agent/tests/dashboard-docs-api.test.ts +125 -0
  85. package/lib/__templates__/pi-agent/tests/dashboard-models-api.test.ts +171 -0
  86. package/lib/__templates__/pi-agent/tests/feishu-channel.test.ts +149 -0
  87. package/lib/__templates__/pi-agent/tests/feishu-streaming-card.test.ts +15 -0
  88. package/lib/__templates__/pi-agent/tests/pi-resources.test.ts +73 -0
  89. package/lib/__templates__/pi-agent/tests/preference-memory.test.ts +43 -0
  90. package/lib/__templates__/pi-agent/tests/session-store.test.ts +61 -0
  91. package/lib/__templates__/pi-agent/tests/smoke/run-smoke.ts +275 -0
  92. package/lib/__templates__/pi-agent/tests/web-fetch.test.ts +157 -0
  93. package/lib/__templates__/pi-agent/tests/web-search.test.ts +208 -0
  94. package/lib/__templates__/pi-agent/tsconfig.json +21 -0
  95. package/lib/__templates__/pi-agent/types/larksuiteoapi-node-sdk.d.ts +113 -0
  96. package/lib/__templates__/taro/.coze +1 -0
  97. package/lib/__templates__/taro/.cozeproj/scripts/validate.sh +8 -0
  98. package/lib/__templates__/taro/package.json +1 -1
  99. package/lib/__templates__/templates.json +24 -0
  100. package/lib/__templates__/vite/.coze +1 -0
  101. package/lib/__templates__/vite/package.json +3 -1
  102. package/lib/__templates__/vite/scripts/validate.sh +10 -0
  103. package/lib/cli.js +13 -2
  104. package/package.json +1 -1
@@ -0,0 +1,65 @@
1
+ import type { ComponentPropsWithoutRef } from "react";
2
+ import { Streamdown } from "streamdown";
3
+ import { code } from "@streamdown/code";
4
+ import { Link } from "react-router-dom";
5
+ import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert";
6
+ import { useFetch } from "../hooks/use-fetch";
7
+
8
+ type DashboardDocDetail = {
9
+ slug: string;
10
+ title: string;
11
+ content: string;
12
+ };
13
+
14
+ type DashboardDocsResponse = {
15
+ selectedDoc: DashboardDocDetail | null;
16
+ };
17
+
18
+ const DEFAULT_DOC_SLUG = "getting-started";
19
+
20
+ function DocsLink(props: ComponentPropsWithoutRef<"a">) {
21
+ const href = typeof props.href === "string" ? props.href : "";
22
+ const isInternalRoute = href.startsWith("/") && !href.startsWith("//");
23
+
24
+ if (isInternalRoute) {
25
+ return (
26
+ <Link to={href} className={props.className} title={props.title}>
27
+ {props.children}
28
+ </Link>
29
+ );
30
+ }
31
+
32
+ return <a {...props} />;
33
+ }
34
+
35
+ export function DocsPage() {
36
+ const { data, error, loading } = useFetch<DashboardDocsResponse>(`/api/docs?slug=${DEFAULT_DOC_SLUG}`);
37
+ const selectedDoc = data?.selectedDoc ?? null;
38
+
39
+ return (
40
+ <section className="p-4 sm:p-5 md:p-6">
41
+ {loading ? (
42
+ <Alert>
43
+ <AlertTitle>加载中</AlertTitle>
44
+ <AlertDescription>正在读取项目文档。</AlertDescription>
45
+ </Alert>
46
+ ) : error ? (
47
+ <Alert className="border-destructive/20 bg-destructive/5 text-destructive">
48
+ <AlertTitle>读取失败</AlertTitle>
49
+ <AlertDescription>错误:{error}</AlertDescription>
50
+ </Alert>
51
+ ) : !selectedDoc ? (
52
+ <Alert>
53
+ <AlertTitle>暂无文档</AlertTitle>
54
+ <AlertDescription>当前项目里还没有可展示的用户文档。</AlertDescription>
55
+ </Alert>
56
+ ) : (
57
+ <div className="docs-content streamdown-content">
58
+ <Streamdown plugins={{ code }} parseIncompleteMarkdown={true} components={{ a: DocsLink }}>
59
+ {selectedDoc.content}
60
+ </Streamdown>
61
+ </div>
62
+ )}
63
+ </section>
64
+ );
65
+ }
@@ -0,0 +1,122 @@
1
+ import React from "react";
2
+ import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
5
+ import { useFetch } from "../hooks/use-fetch";
6
+ import { toast } from "sonner";
7
+
8
+ type ModelOption = {
9
+ value: string;
10
+ label: string;
11
+ };
12
+
13
+ type ModelsConfig = {
14
+ defaultModel: string;
15
+ options: ModelOption[];
16
+ };
17
+
18
+ export function ModelsPage() {
19
+ const { data, error, loading } = useFetch<ModelsConfig>("/api/models");
20
+ const [saving, setSaving] = React.useState(false);
21
+ const [form, setForm] = React.useState<ModelsConfig | null>(null);
22
+
23
+ React.useEffect(() => {
24
+ if (!data) return;
25
+ // Be defensive: if the backend returns an older shape or an error envelope,
26
+ // we still want a non-crashing UI.
27
+ setForm({
28
+ defaultModel: typeof data.defaultModel === "string" ? data.defaultModel : "",
29
+ options: Array.isArray((data as unknown as { options?: unknown }).options)
30
+ ? (data as unknown as { options: ModelOption[] }).options
31
+ : [],
32
+ });
33
+ }, [data]);
34
+
35
+ const saveDefaultModel = async (defaultModel: string) => {
36
+ setSaving(true);
37
+ try {
38
+ const res = await fetch("/api/models", {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({ models: { defaultModel } }),
42
+ });
43
+ const json = (await res.json()) as { ok?: boolean; error?: string };
44
+ if (!res.ok || json.ok === false) {
45
+ throw new Error(json.error || `${res.status} ${res.statusText}`);
46
+ }
47
+ toast.success("已保存(需要重启进程生效)");
48
+ } catch (e) {
49
+ toast.error(`保存失败:${String(e)}`, { duration: 4500 });
50
+ throw e;
51
+ } finally {
52
+ setSaving(false);
53
+ }
54
+ };
55
+
56
+ const options = form?.options ?? [];
57
+ const onDefaultModelChange = async (nextValue: string) => {
58
+ if (!form || nextValue === form.defaultModel) return;
59
+ const previousValue = form.defaultModel;
60
+ setForm({ ...form, defaultModel: nextValue });
61
+ try {
62
+ await saveDefaultModel(nextValue);
63
+ } catch {
64
+ setForm((current) => (current ? { ...current, defaultModel: previousValue } : current));
65
+ }
66
+ };
67
+
68
+ return (
69
+ <section className="space-y-4 px-4 pb-4 pt-0">
70
+ <div className="sticky top-0 z-20 -mx-4 bg-background px-4 pb-3 pt-4">
71
+ <div className="space-y-1 bg-background py-0">
72
+ <div className="space-y-1">
73
+ <h1 className="text-lg font-semibold tracking-tight">模型配置</h1>
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ {loading ? (
79
+ <Alert>
80
+ <AlertTitle>加载中</AlertTitle>
81
+ <AlertDescription>正在读取模型配置。</AlertDescription>
82
+ </Alert>
83
+ ) : error ? (
84
+ <Alert className="border-destructive/20 bg-destructive/5 text-destructive">
85
+ <AlertTitle>读取失败</AlertTitle>
86
+ <AlertDescription>错误:{error}</AlertDescription>
87
+ </Alert>
88
+ ) : form ? (
89
+ options.length > 0 ? (
90
+ <Card className="border-border/60 bg-background/70 shadow-none">
91
+ <CardHeader className="!pb-3">
92
+ <CardTitle className="text-sm">默认模型</CardTitle>
93
+ </CardHeader>
94
+ <CardContent className="pb-5 pt-0">
95
+ <Select
96
+ value={form.defaultModel}
97
+ onValueChange={onDefaultModelChange}
98
+ disabled={saving}
99
+ >
100
+ <SelectTrigger aria-label="选择默认模型">
101
+ <SelectValue placeholder="选择默认模型" />
102
+ </SelectTrigger>
103
+ <SelectContent>
104
+ {options.map((option) => (
105
+ <SelectItem key={option.value} value={option.value}>
106
+ {option.label}
107
+ </SelectItem>
108
+ ))}
109
+ </SelectContent>
110
+ </Select>
111
+ </CardContent>
112
+ </Card>
113
+ ) : (
114
+ <Alert>
115
+ <AlertTitle>没有可选模型</AlertTitle>
116
+ <AlertDescription>当前还没有可用模型,请先补充模型定义。</AlertDescription>
117
+ </Alert>
118
+ )
119
+ ) : null}
120
+ </section>
121
+ );
122
+ }
@@ -0,0 +1,134 @@
1
+ import React from "react";
2
+ import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert";
3
+ import { Badge } from "../components/ui/badge";
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
5
+ import { useFetch } from "../hooks/use-fetch";
6
+
7
+ type Overview = {
8
+ appName: string;
9
+ botStatus: string;
10
+ dashboardUrl: string;
11
+ agentMode: string;
12
+ workspaceDir: string;
13
+ agentDir: string;
14
+ enabledChannels: Array<{ id: string; enabled: boolean }>;
15
+ };
16
+
17
+ export function OverviewPage() {
18
+ const { data, error, loading } = useFetch<Overview>("/api/overview");
19
+ const enabledChannels = data?.enabledChannels ?? [];
20
+ const activeChannelCount = enabledChannels.filter((c) => c.enabled).length;
21
+ const botStatusText = data?.botStatus === "running" ? "运行中" : data?.botStatus === "stopped" ? "已停止" : "未知";
22
+
23
+ return (
24
+ <section className="space-y-4 p-4 sm:p-5 md:p-6">
25
+ <div className="grid gap-4 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)]">
26
+ <Card>
27
+ <CardHeader className="pb-3">
28
+ <CardTitle className="text-base">系统摘要</CardTitle>
29
+ <CardDescription>当前 bot、渠道与工作区的核心状态。</CardDescription>
30
+ </CardHeader>
31
+ <CardContent className="grid gap-3 sm:grid-cols-3">
32
+ <SummaryStat
33
+ label="运行状态"
34
+ value={botStatusText}
35
+ hint={data?.botStatus === "running" ? "服务运行中" : "等待服务状态"}
36
+ />
37
+ <SummaryStat
38
+ label="启用渠道"
39
+ value={String(activeChannelCount)}
40
+ hint={enabledChannels.length ? `${enabledChannels.length} 个渠道已接入` : "暂无渠道信息"}
41
+ />
42
+ <SummaryStat
43
+ label="Agent 模式"
44
+ value={data?.agentMode ?? "--"}
45
+ hint="当前启动模式"
46
+ />
47
+ </CardContent>
48
+ </Card>
49
+
50
+ <Card>
51
+ <CardHeader className="pb-3">
52
+ <CardTitle className="text-base">渠道状态</CardTitle>
53
+ <CardDescription>快速查看各渠道是否已启用。</CardDescription>
54
+ </CardHeader>
55
+ <CardContent className="flex flex-wrap gap-2">
56
+ {enabledChannels.length === 0 ? (
57
+ <Badge variant="outline">暂无数据</Badge>
58
+ ) : (
59
+ enabledChannels.map((channel) => (
60
+ <Badge
61
+ key={channel.id}
62
+ variant={channel.enabled ? "default" : "outline"}
63
+ className="rounded-full px-3 py-1"
64
+ >
65
+ {channel.id} · {channel.enabled ? "已开启" : "已关闭"}
66
+ </Badge>
67
+ ))
68
+ )}
69
+ </CardContent>
70
+ </Card>
71
+ </div>
72
+
73
+ <Card className="overflow-hidden">
74
+ <CardHeader>
75
+ <CardTitle>概览</CardTitle>
76
+ <CardDescription>关键运行信息</CardDescription>
77
+ </CardHeader>
78
+ <CardContent>
79
+ {loading ? (
80
+ <Alert>
81
+ <AlertTitle>加载中</AlertTitle>
82
+ <AlertDescription>正在获取 Dashboard 运行信息。</AlertDescription>
83
+ </Alert>
84
+ ) : error ? (
85
+ <Alert className="border-destructive/20 bg-destructive/5 text-destructive">
86
+ <AlertTitle>请求失败</AlertTitle>
87
+ <AlertDescription>错误:{error}</AlertDescription>
88
+ </Alert>
89
+ ) : (
90
+ <div className="grid gap-3">
91
+ <InfoRow label="面板地址">
92
+ <a
93
+ className="underline decoration-border underline-offset-4 transition-colors hover:text-primary"
94
+ href={data?.dashboardUrl}
95
+ target="_blank"
96
+ rel="noreferrer"
97
+ >
98
+ {data?.dashboardUrl}
99
+ </a>
100
+ </InfoRow>
101
+ <InfoRow label="工作区目录" mono>
102
+ {data?.workspaceDir}
103
+ </InfoRow>
104
+ <InfoRow label="Agent 目录" mono>
105
+ {data?.agentDir}
106
+ </InfoRow>
107
+ </div>
108
+ )}
109
+ </CardContent>
110
+ </Card>
111
+ </section>
112
+ );
113
+ }
114
+
115
+ function SummaryStat(props: { label: string; value: string; hint: string }) {
116
+ return (
117
+ <div className="rounded-2xl border border-border/70 bg-muted/30 p-4">
118
+ <div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{props.label}</div>
119
+ <div className="mt-3 text-2xl font-semibold tracking-tight">{props.value}</div>
120
+ <div className="mt-2 text-sm text-muted-foreground">{props.hint}</div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ function InfoRow(props: { label: string; children: React.ReactNode; mono?: boolean }) {
126
+ return (
127
+ <div className="grid gap-2 rounded-xl border border-border/60 bg-muted/30 px-4 py-3 md:grid-cols-[220px_minmax(0,1fr)] md:items-start">
128
+ <div className="text-sm font-medium text-muted-foreground">{props.label}</div>
129
+ <div className={props.mono ? "[overflow-wrap:anywhere] font-mono text-xs sm:text-sm" : "[overflow-wrap:anywhere] text-sm"}>
130
+ {props.children}
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,167 @@
1
+ export type ChatSessionIdentity = {
2
+ channel: "dashboard" | "feishu" | "wechat";
3
+ isDirectMessage: boolean;
4
+ senderId: string;
5
+ conversationId: string;
6
+ threadId?: string;
7
+ };
8
+
9
+ export type ChatWsClientFrame =
10
+ | { type: "chat.send"; runId: string; session?: ChatSessionIdentity; sessionKey?: string; text: string }
11
+ | { type: "chat.abort"; runId?: string; session?: ChatSessionIdentity; sessionKey?: string }
12
+ | { type: "ping" };
13
+
14
+ export type ChatWsServerFrame =
15
+ | { type: "ack"; runId: string; status: "started" | "in_flight" }
16
+ | { type: "meta"; runId: string; sessionKey: string }
17
+ | { type: "delta"; runId: string; sessionKey: string; delta: string }
18
+ | { type: "done"; runId: string; sessionKey: string; text: string }
19
+ | { type: "error"; runId: string; sessionKey?: string; error: string };
20
+
21
+ export type ChatWsLogFn = (
22
+ level: "debug" | "info" | "warn" | "error",
23
+ msg: string,
24
+ data?: Record<string, unknown>,
25
+ ) => void;
26
+
27
+ export class ChatWsService {
28
+ private ws: WebSocket | null = null;
29
+ private ready: Promise<WebSocket> | null = null;
30
+ private manualClose = false;
31
+ private reconnectTimer: number | null = null;
32
+ private reconnectDelayMs = 300;
33
+
34
+ constructor(
35
+ private readonly opts: {
36
+ resolveUrl: () => string;
37
+ onFrame: (frame: ChatWsServerFrame) => void;
38
+ log?: ChatWsLogFn;
39
+ },
40
+ ) {}
41
+
42
+ private log(level: "debug" | "info" | "warn" | "error", msg: string, data?: Record<string, unknown>) {
43
+ try {
44
+ this.opts.log?.(level, msg, data);
45
+ } catch {
46
+ // ignore
47
+ }
48
+ }
49
+
50
+ async connect(): Promise<WebSocket> {
51
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
52
+ return this.ws;
53
+ }
54
+ if (this.ready) {
55
+ return this.ready;
56
+ }
57
+
58
+ const wsUrl = this.opts.resolveUrl();
59
+ this.log("debug", "ws.connect.begin", { wsUrl });
60
+
61
+ this.ready = new Promise<WebSocket>((resolve, reject) => {
62
+ try {
63
+ const ws = new WebSocket(wsUrl);
64
+ this.ws = ws;
65
+ this.manualClose = false;
66
+
67
+ ws.onopen = () => {
68
+ this.log("info", "ws.open", { wsUrl });
69
+ this.reconnectDelayMs = 300;
70
+ resolve(ws);
71
+ };
72
+
73
+ ws.onerror = () => {
74
+ this.log("warn", "ws.onerror", { wsUrl });
75
+ };
76
+
77
+ ws.onclose = (evt) => {
78
+ this.log("warn", "ws.onclose", { wsUrl, code: evt.code, reason: evt.reason, manual: this.manualClose });
79
+ this.ws = null;
80
+ this.ready = null;
81
+ if (!this.manualClose && this.reconnectTimer == null) {
82
+ const delay = Math.min(2000, this.reconnectDelayMs);
83
+ this.reconnectDelayMs = Math.min(2000, this.reconnectDelayMs * 2);
84
+ this.reconnectTimer = window.setTimeout(() => {
85
+ this.reconnectTimer = null;
86
+ void this.connect();
87
+ }, delay);
88
+ }
89
+ };
90
+
91
+ ws.onmessage = (evt) => {
92
+ let msg: unknown = null;
93
+ try {
94
+ msg = JSON.parse(String(evt.data)) as unknown;
95
+ } catch {
96
+ this.log("warn", "ws.bad-json", { dataType: typeof evt.data });
97
+ return;
98
+ }
99
+ if (!msg || typeof msg !== "object") return;
100
+ const type = (msg as { type?: unknown }).type;
101
+ if (typeof type !== "string") return;
102
+ this.opts.onFrame(msg as ChatWsServerFrame);
103
+ };
104
+ } catch (e) {
105
+ reject(e);
106
+ }
107
+ });
108
+
109
+ try {
110
+ return await this.ready;
111
+ } catch (e) {
112
+ this.ready = null;
113
+ throw e;
114
+ }
115
+ }
116
+
117
+ async sendChat(params: { runId: string; session: ChatSessionIdentity; text: string }): Promise<void> {
118
+ const ws = await this.connect();
119
+ const frame: ChatWsClientFrame = {
120
+ type: "chat.send",
121
+ runId: params.runId,
122
+ session: params.session,
123
+ text: params.text,
124
+ };
125
+ ws.send(JSON.stringify(frame));
126
+ }
127
+
128
+ async sendChatByKey(params: { runId: string; sessionKey: string; text: string }): Promise<void> {
129
+ const ws = await this.connect();
130
+ const frame: ChatWsClientFrame = {
131
+ type: "chat.send",
132
+ runId: params.runId,
133
+ sessionKey: params.sessionKey,
134
+ text: params.text,
135
+ };
136
+ ws.send(JSON.stringify(frame));
137
+ }
138
+
139
+ abort(params: { runId?: string; session?: ChatSessionIdentity; sessionKey?: string }): void {
140
+ const ws = this.ws;
141
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
142
+ return;
143
+ }
144
+ const frame: ChatWsClientFrame = {
145
+ type: "chat.abort",
146
+ ...(params.runId ? { runId: params.runId } : {}),
147
+ ...(params.session ? { session: params.session } : {}),
148
+ ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
149
+ };
150
+ ws.send(JSON.stringify(frame));
151
+ }
152
+
153
+ close(): void {
154
+ if (this.reconnectTimer != null) {
155
+ window.clearTimeout(this.reconnectTimer);
156
+ this.reconnectTimer = null;
157
+ }
158
+ this.manualClose = true;
159
+ try {
160
+ this.ws?.close();
161
+ } catch {
162
+ // ignore
163
+ }
164
+ this.ws = null;
165
+ this.ready = null;
166
+ }
167
+ }