@coze-arch/cli 0.0.19-alpha.502ddf → 0.0.19-alpha.a5388a
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/lib/__templates__/nuxt-vue/app/pages/index.vue +0 -6
- package/lib/__templates__/nuxt-vue/nuxt.config.ts +2 -2
- package/lib/__templates__/pi-agent/AGENTS.md +2 -7
- package/lib/__templates__/pi-agent/README.md +0 -2
- package/lib/__templates__/pi-agent/docs/project-overview.md +15 -9
- package/lib/__templates__/pi-agent/docs/user/getting-started.md +4 -3
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-asr/SKILL.md +10 -4
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-asr/scripts/asr.mjs +9 -0
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-image-gen/SKILL.md +18 -6
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-image-gen/scripts/gen.mjs +9 -0
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-tts/SKILL.md +37 -9
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-tts/scripts/tts.mjs +9 -0
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-video-gen/SKILL.md +30 -17
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-video-gen/scripts/gen.mjs +9 -0
- package/lib/__templates__/pi-agent/src/config.ts +19 -60
- package/lib/__templates__/pi-agent/src/core.ts +0 -1
- package/lib/__templates__/pi-agent/src/dashboard/api/docs.ts +204 -0
- package/lib/__templates__/pi-agent/src/dashboard/index.ts +4 -39
- package/lib/__templates__/pi-agent/src/dashboard/server.ts +12 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/app-layout.tsx +15 -1
- package/lib/__templates__/pi-agent/src/dashboard/web/src/main.tsx +6 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/channels-page.tsx +188 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/chat-page.tsx +11 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/docs-page.tsx +65 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/models-page.tsx +122 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/overview-page.tsx +72 -268
- package/lib/__templates__/pi-agent/src/dashboard/web/src/styles.css +91 -0
- package/lib/__templates__/pi-agent/tests/config.test.ts +1 -63
- package/lib/__templates__/pi-agent/tests/dashboard-docs-api.test.ts +125 -0
- package/lib/cli.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type DashboardDocSummary = {
|
|
5
|
+
slug: string;
|
|
6
|
+
title: string;
|
|
7
|
+
summary: string;
|
|
8
|
+
group: string;
|
|
9
|
+
order: number;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type DashboardDocDetail = DashboardDocSummary & {
|
|
14
|
+
content: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DashboardDocsResponse = {
|
|
18
|
+
docs: DashboardDocSummary[];
|
|
19
|
+
selectedDoc: DashboardDocDetail | null;
|
|
20
|
+
requestedSlug?: string;
|
|
21
|
+
requestedSlugFound: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ParsedFrontmatter = {
|
|
25
|
+
title?: string;
|
|
26
|
+
summary?: string;
|
|
27
|
+
group?: string;
|
|
28
|
+
order?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function listMarkdownFiles(rootDir: string): string[] {
|
|
32
|
+
if (!existsSync(rootDir)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const entries = readdirSync(rootDir, { withFileTypes: true }).sort((left, right) =>
|
|
37
|
+
left.name.localeCompare(right.name)
|
|
38
|
+
);
|
|
39
|
+
const files: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const fullPath = join(rootDir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
files.push(...listMarkdownFiles(fullPath));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
48
|
+
files.push(fullPath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripQuotes(value: string): string {
|
|
56
|
+
if (
|
|
57
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
58
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
59
|
+
) {
|
|
60
|
+
return value.slice(1, -1);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseFrontmatter(raw: string): { meta: ParsedFrontmatter; content: string } {
|
|
66
|
+
const normalized = raw.replace(/\r\n/g, "\n");
|
|
67
|
+
if (!normalized.startsWith("---\n")) {
|
|
68
|
+
return { meta: {}, content: raw };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lines = normalized.split("\n");
|
|
72
|
+
let closingIndex = -1;
|
|
73
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
74
|
+
if (lines[index]?.trim() === "---") {
|
|
75
|
+
closingIndex = index;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (closingIndex === -1) {
|
|
81
|
+
return { meta: {}, content: raw };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const meta: ParsedFrontmatter = {};
|
|
85
|
+
for (const line of lines.slice(1, closingIndex)) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
88
|
+
|
|
89
|
+
const match = trimmed.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
90
|
+
if (!match) continue;
|
|
91
|
+
|
|
92
|
+
const key = match[1]!;
|
|
93
|
+
const value = stripQuotes(match[2]!.trim());
|
|
94
|
+
if (!value) continue;
|
|
95
|
+
|
|
96
|
+
if (key === "order") {
|
|
97
|
+
const parsed = Number(value);
|
|
98
|
+
if (Number.isFinite(parsed)) {
|
|
99
|
+
meta.order = parsed;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (key === "title") meta.title = value;
|
|
105
|
+
if (key === "summary") meta.summary = value;
|
|
106
|
+
if (key === "group") meta.group = value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
meta,
|
|
111
|
+
content: lines.slice(closingIndex + 1).join("\n").trimStart(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractHeading(content: string): string {
|
|
116
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
const match = line.trim().match(/^#\s+(.+)$/);
|
|
119
|
+
if (match) {
|
|
120
|
+
return match[1]!.trim();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function extractSummary(content: string): string {
|
|
127
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
128
|
+
const paragraph: string[] = [];
|
|
129
|
+
let inCodeBlock = false;
|
|
130
|
+
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
if (!trimmed) {
|
|
134
|
+
if (paragraph.length > 0) break;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (trimmed.startsWith("```")) {
|
|
138
|
+
inCodeBlock = !inCodeBlock;
|
|
139
|
+
if (paragraph.length > 0) break;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (inCodeBlock) continue;
|
|
143
|
+
if (trimmed.startsWith("#")) continue;
|
|
144
|
+
if (trimmed.startsWith("- ") || trimmed.startsWith("* ") || /^\d+\.\s/.test(trimmed)) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
paragraph.push(trimmed.replace(/^>\s?/, ""));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return paragraph.join(" ").trim();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createTitleFromSlug(slug: string): string {
|
|
155
|
+
const tail = slug.split("/").at(-1) ?? slug;
|
|
156
|
+
return tail
|
|
157
|
+
.split(/[-_]/g)
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
160
|
+
.join(" ");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function loadDoc(filePath: string, docsDir: string): DashboardDocDetail {
|
|
164
|
+
const relativePath = relative(docsDir, filePath).split(sep).join("/");
|
|
165
|
+
const slug = relativePath.replace(/\.md$/i, "");
|
|
166
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
167
|
+
const parsed = parseFrontmatter(raw);
|
|
168
|
+
const title = parsed.meta.title?.trim() || extractHeading(parsed.content) || createTitleFromSlug(slug);
|
|
169
|
+
const summary = parsed.meta.summary?.trim() || extractSummary(parsed.content);
|
|
170
|
+
const group = parsed.meta.group?.trim() || "其他";
|
|
171
|
+
const order = parsed.meta.order ?? Number.MAX_SAFE_INTEGER;
|
|
172
|
+
const updatedAt = statSync(filePath).mtime.toISOString();
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
slug,
|
|
176
|
+
title,
|
|
177
|
+
summary,
|
|
178
|
+
group,
|
|
179
|
+
order,
|
|
180
|
+
updatedAt,
|
|
181
|
+
content: parsed.content,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function readDocsResponse(args: { docsDir: string; slug?: string }): DashboardDocsResponse {
|
|
186
|
+
const docs = listMarkdownFiles(args.docsDir)
|
|
187
|
+
.map((filePath) => loadDoc(filePath, args.docsDir))
|
|
188
|
+
.sort((left, right) => {
|
|
189
|
+
if (left.order !== right.order) return left.order - right.order;
|
|
190
|
+
return left.title.localeCompare(right.title);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const requestedSlug = args.slug?.trim();
|
|
194
|
+
const selectedDoc = requestedSlug
|
|
195
|
+
? docs.find((doc) => doc.slug === requestedSlug) ?? docs[0] ?? null
|
|
196
|
+
: docs[0] ?? null;
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
docs: docs.map(({ content: _content, ...summary }) => summary),
|
|
200
|
+
selectedDoc,
|
|
201
|
+
requestedSlug,
|
|
202
|
+
requestedSlugFound: requestedSlug ? selectedDoc?.slug === requestedSlug : true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -4,47 +4,9 @@ import type { DashboardServer } from "./types.js";
|
|
|
4
4
|
import type { PiAgentRuntime } from "../agent.js";
|
|
5
5
|
import {
|
|
6
6
|
createFileConfigStore,
|
|
7
|
-
createMemoryConfigStore,
|
|
8
7
|
type ConfigStore,
|
|
9
8
|
} from "./config-store.js";
|
|
10
9
|
|
|
11
|
-
function createFallbackConfigRoot(botConfig: BotAppConfig): Record<string, unknown> {
|
|
12
|
-
return {
|
|
13
|
-
agents: {
|
|
14
|
-
defaults: {
|
|
15
|
-
model: {
|
|
16
|
-
primary:
|
|
17
|
-
botConfig.agent.provider && botConfig.agent.model
|
|
18
|
-
? `${botConfig.agent.provider}/${botConfig.agent.model}`
|
|
19
|
-
: ""
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
models: {
|
|
24
|
-
providers: {}
|
|
25
|
-
},
|
|
26
|
-
channels: {
|
|
27
|
-
feishu: {
|
|
28
|
-
enabled: botConfig.channels.feishu?.enabled ?? false,
|
|
29
|
-
requireMention: botConfig.routing.feishuGroupRequireMention,
|
|
30
|
-
appId: botConfig.channels.feishu?.appId,
|
|
31
|
-
appSecret: botConfig.channels.feishu?.appSecret,
|
|
32
|
-
domain: botConfig.channels.feishu?.domain,
|
|
33
|
-
encryptKey: botConfig.channels.feishu?.encryptKey,
|
|
34
|
-
verificationToken: botConfig.channels.feishu?.verificationToken,
|
|
35
|
-
thinkingReaction: {
|
|
36
|
-
enabled: botConfig.channels.feishu?.thinkingReaction?.enabled ?? true,
|
|
37
|
-
emojiType: botConfig.channels.feishu?.thinkingReaction?.emojiType
|
|
38
|
-
}
|
|
39
|
-
},
|
|
40
|
-
wechat: {
|
|
41
|
-
enabled: botConfig.channels.wechat?.enabled ?? false,
|
|
42
|
-
requireMention: botConfig.routing.wechatGroupRequireMention
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
10
|
export function createDashboard(args: {
|
|
49
11
|
botConfig: BotAppConfig;
|
|
50
12
|
channels: { feishu?: unknown; wechat?: unknown };
|
|
@@ -55,7 +17,10 @@ export function createDashboard(args: {
|
|
|
55
17
|
args.configStore ??
|
|
56
18
|
(args.botConfig.agent.configPath
|
|
57
19
|
? createFileConfigStore(args.botConfig.agent.configPath)
|
|
58
|
-
:
|
|
20
|
+
: undefined);
|
|
21
|
+
if (!configStore) {
|
|
22
|
+
throw new Error("Missing botConfig.agent.configPath for dashboard.");
|
|
23
|
+
}
|
|
59
24
|
|
|
60
25
|
return createDashboardServer({
|
|
61
26
|
runtime: {
|
|
@@ -6,6 +6,7 @@ import express from "express";
|
|
|
6
6
|
import { createServer as createViteServer, type ViteDevServer } from "vite";
|
|
7
7
|
import { WebSocketServer, type RawData, type WebSocket } from "ws";
|
|
8
8
|
import type { DashboardServer, DashboardServerOptions } from "./types.js";
|
|
9
|
+
import { readDocsResponse } from "./api/docs.js";
|
|
9
10
|
import { buildOverviewResponse } from "./api/overview.js";
|
|
10
11
|
import { readChannelsResponse, saveChannelsRequest } from "./api/channels.js";
|
|
11
12
|
import { ValidationError, readModelsResponse, saveModelsRequest } from "./api/models.js";
|
|
@@ -33,6 +34,8 @@ function readHost(explicitHost: string | undefined): string {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
const DASHBOARD_DIR = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const PROJECT_ROOT = resolve(DASHBOARD_DIR, "..", "..");
|
|
38
|
+
const USER_DOCS_DIR = resolve(PROJECT_ROOT, "docs", "user");
|
|
36
39
|
const WEB_ROOT = resolve(DASHBOARD_DIR, "web");
|
|
37
40
|
const WEB_DIST = resolve(WEB_ROOT, "dist");
|
|
38
41
|
|
|
@@ -226,6 +229,15 @@ export function createDashboardServer(options: DashboardServerOptions): Dashboar
|
|
|
226
229
|
}
|
|
227
230
|
});
|
|
228
231
|
|
|
232
|
+
app.get("/api/docs", (req, res) => {
|
|
233
|
+
try {
|
|
234
|
+
const slug = typeof req.query.slug === "string" ? req.query.slug : undefined;
|
|
235
|
+
res.json(readDocsResponse({ docsDir: USER_DOCS_DIR, slug }));
|
|
236
|
+
} catch (err) {
|
|
237
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
229
241
|
app.get("/api/chat/sessions", (_req, res) => {
|
|
230
242
|
try {
|
|
231
243
|
const sessions = options.agentRuntime.listSessionKeys();
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import {
|
|
3
|
+
BookOpenText as DocsIcon,
|
|
4
|
+
Cpu as CpuIcon,
|
|
3
5
|
LayoutDashboard as OverviewIcon,
|
|
4
6
|
Menu as MenuIcon,
|
|
5
7
|
MessageSquare as MessageIcon,
|
|
6
8
|
Moon as MoonIcon,
|
|
9
|
+
Settings as SettingsIcon,
|
|
7
10
|
Sun as SunIcon,
|
|
8
11
|
} from "lucide-react";
|
|
9
12
|
import { NavLink, useLocation } from "react-router-dom";
|
|
@@ -15,6 +18,9 @@ import { useLocalStorageState } from "../hooks/use-local-storage-state";
|
|
|
15
18
|
const navItems = [
|
|
16
19
|
{ to: "/chat", label: "聊天", icon: MessageIcon },
|
|
17
20
|
{ to: "/overview", label: "概览", icon: OverviewIcon },
|
|
21
|
+
{ to: "/docs", label: "文档", icon: DocsIcon },
|
|
22
|
+
{ to: "/models", label: "模型", icon: CpuIcon },
|
|
23
|
+
{ to: "/channels", label: "渠道", icon: SettingsIcon },
|
|
18
24
|
];
|
|
19
25
|
|
|
20
26
|
export function AppLayout(props: { children: React.ReactNode }) {
|
|
@@ -126,7 +132,15 @@ export function AppLayout(props: { children: React.ReactNode }) {
|
|
|
126
132
|
) : null}
|
|
127
133
|
|
|
128
134
|
<div className="flex min-h-0 flex-1 items-stretch">
|
|
129
|
-
<aside className="hidden shrink-0 p-
|
|
135
|
+
<aside className="hidden shrink-0 p-2 md:block xl:hidden">
|
|
136
|
+
<nav className="grid gap-1">
|
|
137
|
+
{navItems.map((item) => (
|
|
138
|
+
<NavItem key={item.to} item={item} compact={true} />
|
|
139
|
+
))}
|
|
140
|
+
</nav>
|
|
141
|
+
</aside>
|
|
142
|
+
|
|
143
|
+
<aside className="hidden shrink-0 p-3 xl:block">
|
|
130
144
|
<nav className="grid gap-1">
|
|
131
145
|
{navItems.map((item) => (
|
|
132
146
|
<NavItem key={item.to} item={item} compact={false} />
|
|
@@ -4,7 +4,10 @@ import "./styles.css";
|
|
|
4
4
|
import "streamdown/styles.css";
|
|
5
5
|
import { AppLayout } from "./components/app-layout";
|
|
6
6
|
import { OverviewPage } from "./pages/overview-page";
|
|
7
|
+
import { ChannelsPage } from "./pages/channels-page";
|
|
7
8
|
import { ChatPage } from "./pages/chat-page";
|
|
9
|
+
import { DocsPage } from "./pages/docs-page";
|
|
10
|
+
import { ModelsPage } from "./pages/models-page";
|
|
8
11
|
|
|
9
12
|
function App() {
|
|
10
13
|
return (
|
|
@@ -13,6 +16,9 @@ function App() {
|
|
|
13
16
|
<Routes>
|
|
14
17
|
<Route path="/" element={<Navigate to="/chat" replace />} />
|
|
15
18
|
<Route path="/overview" element={<OverviewPage />} />
|
|
19
|
+
<Route path="/docs" element={<DocsPage />} />
|
|
20
|
+
<Route path="/models" element={<ModelsPage />} />
|
|
21
|
+
<Route path="/channels" element={<ChannelsPage />} />
|
|
16
22
|
<Route path="/chat" element={<ChatPage />} />
|
|
17
23
|
</Routes>
|
|
18
24
|
</AppLayout>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert";
|
|
3
|
+
import { Button } from "../components/ui/button";
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
|
5
|
+
import { Input } from "../components/ui/input";
|
|
6
|
+
import { Label } from "../components/ui/label";
|
|
7
|
+
import { useFetch } from "../hooks/use-fetch";
|
|
8
|
+
import { toast } from "sonner";
|
|
9
|
+
import { Link } from "react-router-dom";
|
|
10
|
+
|
|
11
|
+
type Channels = {
|
|
12
|
+
routing: {
|
|
13
|
+
feishuGroupRequireMention: boolean;
|
|
14
|
+
wechatGroupRequireMention: boolean;
|
|
15
|
+
};
|
|
16
|
+
feishu: {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
requireMention?: boolean;
|
|
19
|
+
appId?: string;
|
|
20
|
+
domain?: string;
|
|
21
|
+
encryptKey?: string;
|
|
22
|
+
verificationToken?: string;
|
|
23
|
+
appSecret?: string;
|
|
24
|
+
thinkingReaction?: {
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
emojiType?: string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
wechat: {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
requireMention?: boolean;
|
|
32
|
+
implementation?: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function ChannelsPage() {
|
|
37
|
+
const { data, error, loading } = useFetch<Channels>("/api/channels");
|
|
38
|
+
const [saving, setSaving] = React.useState(false);
|
|
39
|
+
const [form, setForm] = React.useState<Channels | null>(null);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (data) setForm(data);
|
|
43
|
+
}, [data]);
|
|
44
|
+
|
|
45
|
+
const patch = (fn: (draft: Channels) => void) => {
|
|
46
|
+
if (!form) return;
|
|
47
|
+
const draft = { ...form, feishu: { ...form.feishu }, wechat: { ...form.wechat } };
|
|
48
|
+
fn(draft);
|
|
49
|
+
setForm(draft);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const onSave = async () => {
|
|
53
|
+
if (!form) return;
|
|
54
|
+
setSaving(true);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch("/api/channels", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify({ channels: form }),
|
|
60
|
+
});
|
|
61
|
+
const json = (await res.json()) as { ok?: boolean; error?: string };
|
|
62
|
+
if (!res.ok || json.ok === false) {
|
|
63
|
+
throw new Error(json.error || `${res.status} ${res.statusText}`);
|
|
64
|
+
}
|
|
65
|
+
toast.success("已保存(需要重启进程生效)");
|
|
66
|
+
} catch (e) {
|
|
67
|
+
toast.error(`保存失败:${String(e)}`, { duration: 4500 });
|
|
68
|
+
} finally {
|
|
69
|
+
setSaving(false);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<section className="space-y-4 px-4 pb-4 pt-0 sm:px-5 sm:pb-5 sm:pt-0 md:px-6 md:pb-6 md:pt-0">
|
|
75
|
+
<div className="sticky top-0 z-20 -mx-4 bg-background px-4 pb-3 pt-4 sm:-mx-5 sm:px-5 sm:pb-4 sm:pt-5 md:-mx-6 md:px-6 md:pb-5 md:pt-6">
|
|
76
|
+
<div className="flex flex-col gap-3 bg-background md:flex-row md:items-start md:justify-between">
|
|
77
|
+
<div className="space-y-1">
|
|
78
|
+
<h1 className="text-lg font-semibold tracking-tight">渠道配置</h1>
|
|
79
|
+
<p className="text-sm text-muted-foreground">管理各渠道的启用状态与接入参数。</p>
|
|
80
|
+
</div>
|
|
81
|
+
<Button
|
|
82
|
+
onClick={onSave}
|
|
83
|
+
disabled={!form || saving || loading || Boolean(error)}
|
|
84
|
+
className="shrink-0"
|
|
85
|
+
>
|
|
86
|
+
{saving ? "保存中…" : "保存"}
|
|
87
|
+
</Button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{loading ? (
|
|
92
|
+
<Alert>
|
|
93
|
+
<AlertTitle>加载中</AlertTitle>
|
|
94
|
+
<AlertDescription>正在读取渠道配置。</AlertDescription>
|
|
95
|
+
</Alert>
|
|
96
|
+
) : error ? (
|
|
97
|
+
<Alert className="border-destructive/20 bg-destructive/5 text-destructive">
|
|
98
|
+
<AlertTitle>读取失败</AlertTitle>
|
|
99
|
+
<AlertDescription>错误:{error}</AlertDescription>
|
|
100
|
+
</Alert>
|
|
101
|
+
) : form ? (
|
|
102
|
+
<>
|
|
103
|
+
<div className="space-y-4">
|
|
104
|
+
<Card className="border-border/60 bg-background/70 shadow-none">
|
|
105
|
+
<CardHeader>
|
|
106
|
+
<div className="flex items-center gap-3">
|
|
107
|
+
<CardTitle>飞书</CardTitle>
|
|
108
|
+
<Link
|
|
109
|
+
to="/docs"
|
|
110
|
+
className="text-sm font-medium text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-primary"
|
|
111
|
+
>
|
|
112
|
+
接入说明
|
|
113
|
+
</Link>
|
|
114
|
+
</div>
|
|
115
|
+
<CardDescription>管理飞书渠道的启用状态与接入凭证。</CardDescription>
|
|
116
|
+
</CardHeader>
|
|
117
|
+
<CardContent className="grid gap-4">
|
|
118
|
+
<CheckboxField
|
|
119
|
+
label="启用状态"
|
|
120
|
+
checked={!!form.feishu.enabled}
|
|
121
|
+
onChange={(checked) => patch((d) => (d.feishu.enabled = checked))}
|
|
122
|
+
/>
|
|
123
|
+
<TextField
|
|
124
|
+
label="App ID"
|
|
125
|
+
value={form.feishu.appId ?? ""}
|
|
126
|
+
onChange={(value) => patch((d) => (d.feishu.appId = value))}
|
|
127
|
+
/>
|
|
128
|
+
<TextField
|
|
129
|
+
label="App Secret"
|
|
130
|
+
value={form.feishu.appSecret ?? ""}
|
|
131
|
+
onChange={(value) => patch((d) => (d.feishu.appSecret = value))}
|
|
132
|
+
/>
|
|
133
|
+
</CardContent>
|
|
134
|
+
</Card>
|
|
135
|
+
</div>
|
|
136
|
+
</>
|
|
137
|
+
) : null}
|
|
138
|
+
</section>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function FieldShell(props: { label: string; children: React.ReactNode; description?: string }) {
|
|
143
|
+
return (
|
|
144
|
+
<div className="space-y-2">
|
|
145
|
+
<Label className="text-xs uppercase tracking-[0.16em] text-muted-foreground">{props.label}</Label>
|
|
146
|
+
{props.children}
|
|
147
|
+
{props.description ? <p className="text-xs text-muted-foreground">{props.description}</p> : null}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function TextField(props: {
|
|
153
|
+
label: string;
|
|
154
|
+
value: string;
|
|
155
|
+
placeholder?: string;
|
|
156
|
+
onChange: (value: string) => void;
|
|
157
|
+
}) {
|
|
158
|
+
return (
|
|
159
|
+
<FieldShell label={props.label}>
|
|
160
|
+
<Input
|
|
161
|
+
className="font-mono text-xs sm:text-sm"
|
|
162
|
+
value={props.value}
|
|
163
|
+
placeholder={props.placeholder}
|
|
164
|
+
onChange={(e) => props.onChange(e.target.value)}
|
|
165
|
+
/>
|
|
166
|
+
</FieldShell>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function CheckboxField(props: {
|
|
171
|
+
label: string;
|
|
172
|
+
checked: boolean;
|
|
173
|
+
onChange: (checked: boolean) => void;
|
|
174
|
+
}) {
|
|
175
|
+
return (
|
|
176
|
+
<FieldShell label={props.label}>
|
|
177
|
+
<label className="flex h-10 items-center gap-3 rounded-md border border-input bg-background/80 px-3 text-sm">
|
|
178
|
+
<input
|
|
179
|
+
type="checkbox"
|
|
180
|
+
className="h-4 w-4 rounded border-border accent-primary"
|
|
181
|
+
checked={props.checked}
|
|
182
|
+
onChange={(e) => props.onChange(e.target.checked)}
|
|
183
|
+
/>
|
|
184
|
+
<span className="text-sm text-foreground">{props.checked ? "已启用" : "未启用"}</span>
|
|
185
|
+
</label>
|
|
186
|
+
</FieldShell>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -8,6 +8,7 @@ import { useLocalStorageState } from "../hooks/use-local-storage-state";
|
|
|
8
8
|
import { Streamdown } from "streamdown";
|
|
9
9
|
import { code } from "@streamdown/code";
|
|
10
10
|
import { cn } from "../utils";
|
|
11
|
+
import { Link } from "react-router-dom";
|
|
11
12
|
|
|
12
13
|
type ChatUiMessage = {
|
|
13
14
|
role: "user" | "assistant";
|
|
@@ -423,6 +424,16 @@ export function ChatPage() {
|
|
|
423
424
|
</Button>
|
|
424
425
|
</div>
|
|
425
426
|
</div>
|
|
427
|
+
<div className="px-1 text-xs text-muted-foreground">
|
|
428
|
+
如需接入飞书机器人,见
|
|
429
|
+
<Link
|
|
430
|
+
to="/docs"
|
|
431
|
+
className="ml-1 underline decoration-border underline-offset-4 transition-colors hover:text-primary"
|
|
432
|
+
>
|
|
433
|
+
飞书接入文档
|
|
434
|
+
</Link>
|
|
435
|
+
。
|
|
436
|
+
</div>
|
|
426
437
|
</div>
|
|
427
438
|
</section>
|
|
428
439
|
);
|
|
@@ -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
|
+
}
|