diff --git a/app/apps/[id]/page.tsx b/app/apps/[id]/page.tsx index ba83046..82413e9 100644 --- a/app/apps/[id]/page.tsx +++ b/app/apps/[id]/page.tsx @@ -2,6 +2,7 @@ import { use, useState, useEffect } from "react"; import { useTranslations } from "next-intl"; +import { triggerLabel } from "@/lib/appTrigger"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -49,6 +50,7 @@ import { useInvalidateAppSkillDiagnostics } from "@/hooks/useAppSkillDiagnostics import AppSkillsSection from "../components/AppSkillsSection"; import AppSkillDiagnostics from "../components/AppSkillDiagnostics"; import AgentMdEditor from "../components/AgentMdEditor"; +import AppAvatar from "../components/AppAvatar"; import ReviewLogDialog from "@/components/ReviewLogDialog"; import ReviewRejectDialog from "@/components/ReviewRejectDialog"; import { useCurrentUser } from "@/hooks/useCurrentUser"; @@ -85,6 +87,8 @@ interface AppInfo { app_type: string; platform: string; ai_model: string; + /** 头像 URL:内置头像静态路径或上传后的 OSS 读代理路径;空=按名称生成占位 */ + avatar_url?: string | null; agent_md?: string | null; /** legacy 提示词绑定;用来判断 Agent.md 区块该说「升级」还是「创建」 */ prompt_id?: number | null; @@ -96,6 +100,8 @@ interface AppInfo { visibility?: string; /** 创建者(提交人)用户ID,自审判定用 */ user_id?: number | null; + /** 作者显示名(nickname 优先退 username)。用户被删除时为空 */ + author?: string | null; owner_dept_id?: number | null; owner_tenant_id?: number | null; } @@ -245,10 +251,10 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string // 审核人不能审自己提交的对象(超管除外,后端违者 403) const selfReview = isSelfReview(user?.id, appInfo.user_id, checkSuperAdmin(user)); // 状态徽标并入基本信息首行;动作/提示另起一行,且只在真有内容时渲染 + // 提交审核按钮已移到标题右侧,草稿态下这条 footer 便没有内容了——留着就是一条 + // 空的分隔线。rejected 仍要留:那里有"查看驳回理由"的链接,塞不进 hover。 const reviewFooterVisible = - status === "draft" || - status === "rejected" || - (status === "pending_review" && canReview); + status === "rejected" || (status === "pending_review" && canReview); return (
{t("back")} +

{appInfo.name} @@ -281,12 +292,31 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string {appInfo.app_type !== "Custom" && ( - {t("basicInfo")} + {/* 动作按钮放标题右侧,与「已绑定工具」「已绑定 Skill」两区一致。 + 原先它单独占一行、下面还跟一句说明,把一屏里最靠上的位置让给了 + 一个大多数时候不需要点的按钮;说明文字改挂到按钮的 hover 上。 */} +
+ {t("basicInfo")} + {(status === "draft" || status === "rejected") && ( + + )} +
- + {/* 6 列:触发方式 / 平台 / AI 模型 / 作者 / 创建时间 / 状态。 + 加了作者之后是 6 格,留在 5 列会让最后一格独占一行。 */} +
{t("appType")}
- {appInfo.app_type} + {triggerLabel(appInfo.app_type, t)}
{t("platform")}
@@ -296,6 +326,12 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
{t("aiModel")}
{appInfo.ai_model}
+ {/* 作者。用户被删除时后端给 null——显示 — 而不是整格消失: + 这是固定列的网格,少一格会让后面的列错位。 */} +
+
{t("author")}
+
{appInfo.author || "—"}
+
{tc("createdAt")}
{formatDate(appInfo.created_at)}
@@ -310,19 +346,8 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string )} {/* 审核动作与状态提示:仅在确有内容时才占一行,已发布应用不留空带 */} {reviewFooterVisible && ( -
+
- {(status === "draft" || status === "rejected") && ( - - )} {status === "pending_review" && canReview && (selfReview ? ( @@ -361,9 +386,6 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string

)} - {status === "draft" && ( -

{tr("draftOwnerOnlyHint")}

- )}
)} @@ -392,8 +414,13 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string {/* 以下工具统计 / 绑定列表仅非 Custom 应用展示 */} {appInfo.app_type !== "Custom" && ( <> - {/* 工具统计 */} - {statistics && ( + {/* + 工具统计。没绑工具就整行不显示——三个 0 既不提供信息,还占掉一屏顶部 + 最显眼的位置。这么判是安全的:后端的统计就是遍历 app_tools 绑定关系算 + 出来的(tools.py get_app_tools_statistics),零绑定时三个数必然都是 0, + 不存在"有历史调用但被藏起来"的情况。 + */} + {statistics && appTools.length > 0 && (
diff --git a/app/apps/components/AgentMdEditor.tsx b/app/apps/components/AgentMdEditor.tsx index 8cbb63c..0ec9191 100644 --- a/app/apps/components/AgentMdEditor.tsx +++ b/app/apps/components/AgentMdEditor.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Textarea } from "@/components/ui/textarea"; @@ -13,16 +12,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { AlertCircle, AlertTriangle, @@ -30,7 +19,6 @@ import { FileText, Loader2, Sparkles, - Undo2, X, } from "lucide-react"; import { useAgentMd, type AgentMdValidationError } from "@/hooks/useAgentMd"; @@ -62,7 +50,7 @@ export default function AgentMdEditor({ appId, platform, promptId, onChanged }: const { user } = useCurrentUser(); const canManage = checkSuperAdmin(user); - const { agentMd, loading, save, generate, remove, fetchExport } = useAgentMd(appId); + const { agentMd, loading, save, generate, fetchExport } = useAgentMd(appId); const [content, setContent] = useState(""); const [dirty, setDirty] = useState(false); @@ -72,7 +60,6 @@ export default function AgentMdEditor({ appId, platform, promptId, onChanged }: const [saveWarnings, setSaveWarnings] = useState([]); const [exportContent, setExportContent] = useState(null); const [exportLoading, setExportLoading] = useState(false); - const [revertDialogOpen, setRevertDialogOpen] = useState(false); const isWechat = platform === "Wechat"; const isLegacy = agentMd?.is_legacy !== false; @@ -114,17 +101,6 @@ export default function AgentMdEditor({ appId, platform, promptId, onChanged }: } }; - const handleRevert = async () => { - const ok = await remove(); - if (ok) { - setRevertDialogOpen(false); - setDirty(false); - setContent(""); - setValidationErrors([]); - setSaveWarnings([]); - onChanged?.(); - } - }; const handleExportView = async () => { setExportLoading(true); @@ -157,14 +133,15 @@ export default function AgentMdEditor({ appId, platform, promptId, onChanged }:
+ {/* + 标题直接写「角色设定」,不再显示文件名 Agent.md,也不再挂状态徽章: + 标题旁边写着「角色设定」、下面又挂一个写着「角色设定」的彩色 Badge, + 是同一句话说两遍。isLegacy 的状态由下面那段说明文字承担 + (legacyDesc / noAgentMdDesc 已经把处境讲清楚了)。 + */} - Agent.md - {isLegacy ? ( - {t("legacyMode")} - ) : ( - {t("agentMdMode")} - )} + {t("agentMdMode")} {!isLegacy && canManage && (
@@ -181,15 +158,6 @@ export default function AgentMdEditor({ appId, platform, promptId, onChanged }: )} {t("exportView")} - + {value && ( + + )} +
+

{t("avatarHint")}

+
+
+ + {error &&

{error}

} + +
+ {BUILTIN_AVATARS.map((url) => ( + + ))} +
+
+ ); +} diff --git a/app/apps/page.tsx b/app/apps/page.tsx index 1b130ff..16b5a5d 100644 --- a/app/apps/page.tsx +++ b/app/apps/page.tsx @@ -2,6 +2,9 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; +import { triggerLabel } from "@/lib/appTrigger"; +import AppAvatarPicker from "./components/AppAvatarPicker"; +import AppAvatar from "./components/AppAvatar"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { @@ -69,8 +72,9 @@ interface App { id: number; name: string; description: string; - app_type: "Chat" | "Tool" | "Plugin" | "Subscription" | "Custom"; - platform: "Web" | "Wechat" | "Feishu" | "iOS" | "Android"; + app_type: "Chat" | "Subscription" | "Email" | "Custom" | "Tool" | "Plugin"; + platform: "Web" | "Wechat" | "Plugin" | "Feishu" | "iOS" | "Android"; + avatar_url?: string | null; user_id: number; ai_model: string; prompt_id: number | null; @@ -101,13 +105,6 @@ interface Dataset { dimension?: number; } -interface Prompt { - id: number; - role: string; - content: string; - is_active?: boolean; -} - interface WechatAgent { agentid: number; name: string; @@ -124,23 +121,13 @@ const platformIcons: Record = { Android: Smartphone, }; -// 平台颜色映射 -const platformColors: Record = { - Web: "bg-primary/10 text-primary", - Wechat: "bg-green-100 text-green-800", // 保持品牌识别色 - Feishu: "bg-purple-100 text-purple-800", // 保持品牌识别色 - iOS: "bg-muted text-muted-foreground", - Android: "bg-orange-100 text-orange-800", // 保持品牌识别色 -}; +// 触发方式与平台标签一律中性灰(Badge 的 secondary)。 +// +// 原先每个取值一种颜色:紫、绿、黄、蓝、翠、橙……一屏卡片下来像调色板,而这些 +// 颜色不承载任何含义——"聊天"是紫的、"Web"也是紫的,读者得先学会一套配色表才 +// 知道颜色没在说话。真正需要抢眼的是异常状态(草稿/待审/驳回,见 reviewStatus), +// 颜色留给它们才有对比度。详情页那两个标签一直是中性的 outline,这里跟它对齐。 -// 应用类型颜色映射 -const appTypeColors: Record = { - Chat: "bg-primary/10 text-primary", - Tool: "bg-yellow-100 text-yellow-800", // 保持识别色 - Plugin: "bg-purple-100 text-purple-800", // 保持识别色 - Subscription: "bg-blue-100 text-blue-800", // 订阅聚合 - Custom: "bg-slate-100 text-slate-800", // 自定义渲染 -}; // 判断是否为订阅聚合应用 const isStreamApp = (app: App) => app.app_type === "Subscription"; @@ -155,7 +142,6 @@ export default function AppsPage() { const [apps, setApps] = useState([]); const [datasets, setDatasets] = useState([]); - const [prompts, setPrompts] = useState([]); const [wechatAgents, setWechatAgents] = useState([]); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); @@ -197,8 +183,10 @@ export default function AppsPage() { const [formData, setFormData] = useState<{ name: string; description: string; - app_type: "Chat" | "Tool" | "Plugin" | "Subscription" | "Custom"; - platform: "Web" | "Wechat" | "Feishu" | "iOS" | "Android"; + app_type: "Chat" | "Subscription" | "Email" | "Custom" | "Tool" | "Plugin"; + platform: "Web" | "Wechat" | "Plugin" | "Feishu" | "iOS" | "Android"; + // 空串 = 用户主动清空(后端据此落 NULL);null = 本来就没设过 + avatar_url: string | null; ai_model: string; prompt_id: number | null; dataset_ids: string[]; @@ -210,6 +198,7 @@ export default function AppsPage() { description: "", app_type: "Chat", platform: "Web", + avatar_url: null, ai_model: "deepseek", prompt_id: null, dataset_ids: [], @@ -240,14 +229,6 @@ export default function AppsPage() { } }, []); - const loadPrompts = useCallback(async () => { - try { - const response = await axios.get("/api/prompts"); - setPrompts(response.data || []); - } catch { - // silently fail - prompts will show as empty - } - }, []); const loadWechatAgents = useCallback(async () => { try { @@ -269,7 +250,7 @@ export default function AppsPage() { dataLoadedRef.current = true; // 并行加载所有数据 - Promise.all([loadApps(), loadDatasets(), loadPrompts()]).catch(() => { + Promise.all([loadApps(), loadDatasets()]).catch(() => { dataLoadedRef.current = false; }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -336,6 +317,7 @@ export default function AppsPage() { description: "", app_type: "Chat", platform: "Web", + avatar_url: null, ai_model: "deepseek", prompt_id: null, dataset_ids: [], @@ -480,6 +462,7 @@ export default function AppsPage() { description: app.description, app_type: app.app_type, platform: app.platform, + avatar_url: app.avatar_url ?? null, ai_model: app.ai_model, prompt_id: app.prompt_id, dataset_ids: datasetIds, @@ -737,7 +720,9 @@ export default function AppsPage() { return ( -
+
+ +
)} +
@@ -774,12 +760,12 @@ export default function AppsPage() { })()} - - {app.app_type} + + {triggerLabel(app.app_type, t)} - + {app.platform} @@ -953,7 +939,8 @@ export default function AppsPage() { }`} > -
+
+
{/* 「默认」跟在名称后面,与表格视图一致(那边一直是内联的, 只有卡片单独占一行)。 @@ -990,10 +977,10 @@ export default function AppsPage() { ) : null; })()} - - {app.app_type} + + {triggerLabel(app.app_type, t)} - + {app.platform} @@ -1217,6 +1204,15 @@ export default function AppsPage() { />
+
+ + setFormData({ ...formData, avatar_url: v })} + /> +
+
-
+ {/* + 触发方式 / 平台 / AI 模型 同一行。AI 模型对 Subscription、Custom + 不适用,隐藏时列数跟着降到 2——否则会空出三分之一,看着像少了个字段。 + */} +
@@ -1297,6 +1316,7 @@ export default function AppsPage() { Web {t("wechat")} + {t("pluginPlatform")} {t("feishu")} iOS Android @@ -1304,6 +1324,25 @@ export default function AppsPage() {
+ + {formData.app_type !== "Subscription" && formData.app_type !== "Custom" && ( +
+ + +
+ )}
{/* Stream 应用订阅源管理 */} @@ -1512,77 +1551,31 @@ export default function AppsPage() {
)} - {/* AI 模型和提示词(Subscription / Custom 应用不需要,隐藏) */} - {formData.app_type !== "Subscription" && formData.app_type !== "Custom" && ( -
+ {/* + 角色设定单独一行:它是这个数字员工的说明书入口,和上面几个下拉不是 + 一类东西,挤在同一行会被当成"又一个选项"。 + 新建态没有 editingApp、也就没有 id 可跳,整块不显示——角色在应用 + 建好之后编辑(后端建应用时已自动铺一份起始角色)。 + */} + {editingApp && + formData.app_type !== "Subscription" && + formData.app_type !== "Custom" && (
- - -
- - {editingApp?.agent_md != null ? ( - // 已升级 Agent.md 的应用:提示词选择区替换为模式徽标 + 详情页编辑入口 -
- -
- - - {ts("agentMdMode")} - - -
-

{ts("agentMdModeDesc")}

-
- ) : ( -
- - + + {ts("editAgentMd")} +
- )} -
- )} +

{ts("agentMdModeDesc")}

+
+ )} {/* 关联知识库(Stream 应用不显示) */} {formData.app_type !== "Subscription" && ( diff --git a/hooks/useAgentMd.ts b/hooks/useAgentMd.ts index 8269e39..a9eed47 100644 --- a/hooks/useAgentMd.ts +++ b/hooks/useAgentMd.ts @@ -104,20 +104,6 @@ export const useAgentMd = (appId: number | null) => { } }; - // 回退到提示词模式(DELETE 置 NULL) - const remove = async (): Promise => { - if (!appId) return false; - try { - await axios.delete(`/api/v1/apps/${appId}/agent-md`); - toast.success(t("revertSuccess")); - mutate(); - return true; - } catch (error: any) { - console.error("Delete agent.md error:", error); - return false; - } - }; - // 导出视图(?export=true 合成只读版) const fetchExport = async (): Promise => { if (!appId) return null; @@ -136,7 +122,6 @@ export const useAgentMd = (appId: number | null) => { error, save, generate, - remove, fetchExport, refresh: mutate, }; diff --git a/lib/appAvatar.ts b/lib/appAvatar.ts new file mode 100644 index 0000000..6dcf309 --- /dev/null +++ b/lib/appAvatar.ts @@ -0,0 +1,69 @@ +/** + * 数字员工头像。 + * + * `apps.avatar_url` 存的是**一个 URL 字符串**,两种来源共用一列: + * · 内置头像 → `/avatars/.svg`(随代码走的静态资源) + * · 用户上传 → `/api/oss/app-avatars/...`(OSS 读代理) + * 浏览器眼里它们是同一种东西,所以渲染侧不做来源判断,直接塞 。 + * + * 没设头像(绝大多数存量应用)不留空白,回落到"名称首字 + 一个按名字定死的底色"。 + * 底色必须**由名字算出来**而不是随机或按 id:同一个数字员工在列表、详情、聊天里 + * 得是同一个颜色,否则用户会以为看到的是两个不同的东西。 + */ + +/** 内置头像。value 就是存进 avatar_url 的那截路径。 */ +export const BUILTIN_AVATARS = [ + "/avatars/bot.svg", + "/avatars/support.svg", + "/avatars/analyst.svg", + "/avatars/reviewer.svg", + "/avatars/writer.svg", + "/avatars/engineer.svg", + "/avatars/finance.svg", + "/avatars/scheduler.svg", +] as const; + +/** 上传头像在 OSS 里的分类目录 */ +export const AVATAR_OSS_CATEGORY = "app-avatars"; + +/** 上传限制:头像就该是张小图,5MB 已经很宽松 */ +export const AVATAR_MAX_BYTES = 5 * 1024 * 1024; + +/** + * 占位文字:取名称的第一个字符。 + * + * 中文取一个字就够("财经助理"→"财"),英文名同理取首字母。不取两个字符: + * 小尺寸圆角块里塞两个汉字会挤成一团黑。 + */ +export function avatarFallbackText(name: string | undefined | null): string { + const trimmed = (name ?? "").trim(); + if (!trimmed) return "?"; + // 用 Array.from 而不是 [0]:emoji 和部分生僻字是代理对,按 UTF-16 下标切会拿到半个字符 + return Array.from(trimmed)[0].toUpperCase(); +} + +/** 是内置头像吗(决定选择器里哪一格显示为选中) */ +export function isBuiltinAvatar(url: string | undefined | null): boolean { + return !!url && (BUILTIN_AVATARS as readonly string[]).includes(url); +} + +/** + * 上传前把文件改成唯一名。 + * + * OSS 的 objectKey = `/<年月>/<原文件名>`,**服务端不做去重**(实测: + * 同名两次 presign 返回同一个 key)。头像又是重名重灾区——大家都叫 avatar.png、 + * logo.png、头像.png。撞上了不会报错,只会让一个数字员工的脸悄悄变成另一个的, + * 而且先传的那个人永远不知道。 + * + * 扩展名保留:OSS 按后缀推 content-type,丢了浏览器可能当附件下载而不是显示。 + */ +export function uniqueAvatarFilename(originalName: string): string { + const m = /\.[a-zA-Z0-9]{1,8}$/.exec(originalName || ""); + const ext = m ? m[0].toLowerCase() : ""; + const rand = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : // 老浏览器兜底:时间戳+随机串,重复概率同样可以忽略 + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + return `${rand}${ext}`; +} diff --git a/lib/appTrigger.ts b/lib/appTrigger.ts new file mode 100644 index 0000000..1d51367 --- /dev/null +++ b/lib/appTrigger.ts @@ -0,0 +1,27 @@ +/** + * 触发方式(列名沿用 app_type)的显示名。 + * + * **不要直接渲染 app_type 的原始值**:底层存的 `Subscription` 在界面上叫「定时任务」, + * 两者故意不一致——前端 isStreamApp 靠这个值决定要不要显示订阅源管理,换成新值就得 + * 迁移存量数据并改 8 处调用,而收益只是名字好看。 + * + * 放在 lib 而不是某个页面里:列表页和详情页都要用,两处各写一份迟早分叉(详情页 + * 一度就还在显示原始的 "Chat")。 + * + * Tool / Plugin 不再是可选项,但存量数据里可能还有,仍给出各自的旧文案, + * 不至于显示成空白。 + */ +export const TRIGGER_LABEL_KEY: Readonly> = { + Chat: "chatType", + Subscription: "subscriptionType", + Email: "emailType", + Custom: "customType", + Tool: "toolType", + Plugin: "pluginType", +}; + +/** 认不出的值原样显示——比显示空白强,也让"哪儿冒出个新值"看得见 */ +export function triggerLabel(appType: string, t: (key: never) => string): string { + const key = TRIGGER_LABEL_KEY[appType]; + return key ? t(key as never) : appType; +} diff --git a/messages/en/apps.json b/messages/en/apps.json index 76b6e24..9c79517 100644 --- a/messages/en/apps.json +++ b/messages/en/apps.json @@ -15,9 +15,10 @@ "appNamePlaceholder": "Enter digital employee name", "appDesc": "Description", "appDescPlaceholder": "Enter digital employee description", - "appType": "Type", + "appType": "Trigger", "platform": "Platform", "aiModel": "AI Model", + "author": "Author", "prompt": "Prompt", "promptPlaceholder": "Select prompt (optional)", "noPrompt": "None", @@ -45,9 +46,11 @@ "workflowConfig": "Workflow Config", "chatType": "Chat", "toolType": "Tool", - "subscriptionType": "Subscription", + "subscriptionType": "Scheduled", "pluginType": "Plugin", "customType": "Custom", + "emailType": "Email", + "pluginPlatform": "Plugin", "customView": "Custom View", "customViewPlaceholder": "Select a view to render", "customViewHelp": "Once selected, the chosen view renders below the digital employee's basic info when opened.", @@ -168,5 +171,14 @@ "weeklyReport": "Weekly Report", "timezone": "Timezone", "autoSelectDatasets": "Auto-select knowledge bases", - "tableHeaderSkills": "Skills" + "tableHeaderSkills": "Skills", + "avatar": "Avatar", + "avatarUpload": "Upload image", + "avatarUploading": "Uploading…", + "avatarClear": "Clear", + "avatarHint": "Pick one below, or upload your own (max 5MB)", + "avatarBuiltinPick": "Use this avatar", + "avatarMustBeImage": "Please choose an image file", + "avatarTooLarge": "Image must be under 5MB", + "avatarUploadFailed": "Upload failed, please try again" } diff --git a/messages/en/skills.json b/messages/en/skills.json index 3099c99..b64755b 100644 --- a/messages/en/skills.json +++ b/messages/en/skills.json @@ -115,32 +115,28 @@ "unbindSuccess": "Skill unbound", "confirmUnbind": "Unbind this skill?", "unbindWarning": "The skill will no longer be injected into this digital employee's system prompt.", - "legacyMode": "Prompt mode", - "agentMdMode": "Agent.md mode", - "agentMdModeDesc": "Prompt selection is managed by Agent.md; edit it on the digital employee detail page", - "editAgentMd": "Edit Agent.md", - "legacyDesc": "This digital employee uses the legacy prompt mode. Upgrade to Agent.md to manage the system prompt and skills in one file (the model stays in the digital employee's AI model setting).", - "upgradeToAgentMd": "Upgrade to Agent.md", - "noAgentMdDesc": "This digital employee has no Agent.md yet. Create one to manage the system prompt and skills in a single file (the model stays in the digital employee's AI model setting).", - "createAgentMd": "Create Agent.md", - "upgradeSuccess": "Upgraded to Agent.md", - "upgradeFailed": "Failed to upgrade to Agent.md", - "agentMdAlreadyExists": "Agent.md already exists, loaded the current version", - "wechatUpgradeDisabled": "Wechat digital employees cannot be upgraded to Agent.md yet", + "legacyMode": "Old prompt", + "agentMdMode": "Role", + "agentMdModeDesc": "Who this digital employee is, what it does and the rules it follows all live in its role — edit it on the detail page", + "editAgentMd": "Edit role", + "legacyDesc": "This digital employee still uses the old prompt. After upgrading, its role and its skills are managed together, which is easier to follow (the model is still chosen in “AI model” above).", + "upgradeToAgentMd": "Upgrade to a role definition", + "noAgentMdDesc": "This digital employee has no role definition yet. Create one to describe who it is and what it does, and manage its skills in the same place (the model is still chosen in “AI model” above).", + "createAgentMd": "Create role definition", + "upgradeSuccess": "Upgraded to a role definition", + "upgradeFailed": "Failed to upgrade", + "agentMdAlreadyExists": "A role definition already exists; loaded the current version", + "wechatUpgradeDisabled": "Wechat digital employees cannot use a role definition yet", "agentMdPlaceholder": "## Role\n...\n\n## How to work\n...", - "agentMdHelp": "Full Agent.md text. The frontmatter is optional and only accepts temperature / max_tool_rounds — the model comes from the digital employee's AI model setting and has no effect here. Validation errors are reported with line numbers on save", - "agentMdSaved": "Agent.md saved", - "agentMdSaveFailed": "Failed to save Agent.md", - "agentMdValidationFailed": "Agent.md validation failed", + "agentMdHelp": "Describe who this digital employee is, what it does and does not do, and how it should answer. Pick the model in “AI model” above — setting it here has no effect.", + "agentMdSaved": "Role definition saved", + "agentMdSaveFailed": "Failed to save the role definition", + "agentMdValidationFailed": "Role definition validation failed", "agentMdSaveWarningsTitle": "Saved, with {count, plural, one {# note} other {# notes}}", - "agentMdSaveWarningsHint": "The editor above now holds the normalized text as it was stored, so it matches what the runtime reads.", + "agentMdSaveWarningsHint": "The text above now shows exactly what was saved.", "lineNumber": "Line {line}:", "exportView": "Export view", - "exportViewNote": "Read-only derived view: the model and the skills / tools / datasets bindings are composed by the system and never written back. The model's source of truth is the digital employee's AI model setting; bindings live in their association tables.", - "confirmRevert": "Revert to prompt mode?", - "revertWarning": "Agent.md will be removed and the digital employee falls back to the legacy prompt configuration. This cannot be undone.", - "revertToPrompt": "Revert to prompt", - "revertSuccess": "Reverted to prompt mode", + "exportViewNote": "A read-only view the system assembles for you: the chosen model plus the skills, tools and knowledge bases this employee is bound to. Editing here is not saved — go back to the previous screen to make changes.", "assetFilesSection": "Reference docs & asset files", "assetFilesDesc": "Any skill can carry files, whether or not it is executable. Once published, text documents of kind \"reference\" can be read on demand by the model through skill_view — just write \"see references/xxx.md\" in the body instead of inlining the whole text. Scripts and binary material are only used inside the sandbox container of an executable skill.", "assetReadable": "Model can read on demand", diff --git a/messages/zh-CN/apps.json b/messages/zh-CN/apps.json index 459b256..73c6bda 100644 --- a/messages/zh-CN/apps.json +++ b/messages/zh-CN/apps.json @@ -15,9 +15,10 @@ "appNamePlaceholder": "输入数字员工名称", "appDesc": "描述", "appDescPlaceholder": "输入数字员工描述", - "appType": "类型", + "appType": "触发方式", "platform": "平台", "aiModel": "AI 模型", + "author": "作者", "prompt": "提示词", "promptPlaceholder": "选择提示词(可选)", "noPrompt": "不选择", @@ -43,11 +44,13 @@ "viewContent": "查看内容", "viewReport": "查看报告", "workflowConfig": "工作流配置", - "chatType": "Chat - 聊天类", + "chatType": "聊天", "toolType": "Tool - 工具类", - "subscriptionType": "Subscription - 订阅聚合", + "subscriptionType": "定时任务", "pluginType": "Plugin - 插件类", - "customType": "Custom - 自定义", + "customType": "自定义", + "emailType": "邮件", + "pluginPlatform": "插件", "customView": "自定义视图", "customViewPlaceholder": "选择要渲染的视图", "customViewHelp": "选定后,打开该数字员工时会在基本信息下方渲染对应视图。", @@ -168,5 +171,14 @@ "weeklyReport": "周报", "timezone": "时区", "autoSelectDatasets": "自动选择知识库", - "tableHeaderSkills": "技能" + "tableHeaderSkills": "技能", + "avatar": "头像", + "avatarUpload": "上传图片", + "avatarUploading": "上传中…", + "avatarClear": "清除", + "avatarHint": "从下面选一个,或上传自己的图片(5MB 以内)", + "avatarBuiltinPick": "使用这个头像", + "avatarMustBeImage": "请选择图片文件", + "avatarTooLarge": "图片不能超过 5MB", + "avatarUploadFailed": "上传失败,请重试" } diff --git a/messages/zh-CN/skills.json b/messages/zh-CN/skills.json index ad2c978..72654fa 100644 --- a/messages/zh-CN/skills.json +++ b/messages/zh-CN/skills.json @@ -115,32 +115,28 @@ "unbindSuccess": "Skill 解绑成功", "confirmUnbind": "确定解绑该 Skill?", "unbindWarning": "解绑后该 Skill 不再注入此数字员工的系统提示词。", - "legacyMode": "提示词模式", - "agentMdMode": "Agent.md 模式", - "agentMdModeDesc": "提示词由 Agent.md 接管,请到数字员工详情页编辑", - "editAgentMd": "编辑 Agent.md", - "legacyDesc": "该数字员工使用旧提示词模式。升级为 Agent.md 后,系统提示词与 Skills 在一份文件中管理(模型仍在数字员工的「AI 模型」设置里配置)。", - "upgradeToAgentMd": "升级为 Agent.md", - "noAgentMdDesc": "该数字员工还没有 Agent.md。创建后,系统提示词与 Skills 在一份文件中管理(模型仍在数字员工的「AI 模型」设置里配置)。", - "createAgentMd": "创建 Agent.md", - "upgradeSuccess": "已升级为 Agent.md", - "upgradeFailed": "升级 Agent.md 失败", - "agentMdAlreadyExists": "Agent.md 已存在,已加载当前版本", - "wechatUpgradeDisabled": "企微(Wechat)数字员工暂不支持升级为 Agent.md", + "legacyMode": "旧提示词", + "agentMdMode": "角色设定", + "agentMdModeDesc": "这个数字员工是谁、做什么、有哪些规矩,都写在角色设定里,到详情页编辑", + "editAgentMd": "编辑角色", + "legacyDesc": "这个数字员工还在用旧的提示词。升级后,它的角色说明和技能会放在一起管理,改起来更直观(模型仍在上方「AI 模型」里选)。", + "upgradeToAgentMd": "升级为角色设定", + "noAgentMdDesc": "这个数字员工还没有角色设定。创建后可以在这里写清它是谁、做什么,技能也一并在这儿管(模型仍在上方「AI 模型」里选)。", + "createAgentMd": "创建角色设定", + "upgradeSuccess": "已升级为角色设定", + "upgradeFailed": "升级角色设定失败", + "agentMdAlreadyExists": "角色设定已存在,已加载当前版本", + "wechatUpgradeDisabled": "企微的数字员工暂时还不能用角色设定", "agentMdPlaceholder": "## 角色\n...\n\n## 工作方式\n...", - "agentMdHelp": "Agent.md 全文;frontmatter 可选,只支持 temperature / max_tool_rounds(模型在数字员工的「AI 模型」设置里改,写在这里不生效)。保存时校验失败会按行号提示", - "agentMdSaved": "Agent.md 已保存", - "agentMdSaveFailed": "保存 Agent.md 失败", - "agentMdValidationFailed": "Agent.md 校验失败", + "agentMdHelp": "在这里写这个数字员工是谁、做什么、不做什么、怎么回答。模型请到上方「AI 模型」里选,写在这里不生效。", + "agentMdSaved": "角色设定已保存", + "agentMdSaveFailed": "保存角色设定失败", + "agentMdValidationFailed": "角色设定校验失败", "agentMdSaveWarningsTitle": "已保存,但有 {count} 条提示", - "agentMdSaveWarningsHint": "上方编辑器已按入库后的归一化全文回填,与库里的内容一致。", + "agentMdSaveWarningsHint": "上方内容已更新为保存后的实际内容,与系统里的一致。", "lineNumber": "第 {line} 行:", "exportView": "导出视图", - "exportViewNote": "只读派生视图:model 与 skills / tools / datasets 由系统合成,永不回写。model 的真源是数字员工的「AI 模型」设置,绑定的真源是关联表。", - "confirmRevert": "回退到提示词模式?", - "revertWarning": "将删除 Agent.md,数字员工回落到旧提示词配置。此操作不可撤销。", - "revertToPrompt": "回退到提示词", - "revertSuccess": "已回退到提示词模式", + "exportViewNote": "这是系统自动拼出来的完整视图,包含所选模型和已绑定的技能、工具、知识库,供查看和存档用。在这里改不会保存——要改请回上一屏。", "assetFilesSection": "参考文档与资产文件", "assetFilesDesc": "任何 Skill 都可以附文件,与「是否可执行」无关。类型为「参考」的文本文档一经发布,模型就能用 skill_view 按需读取——正文里直接写「详见 references/xxx.md」即可让它去读,无需把全文塞进正文。脚本与二进制素材只在可执行 Skill 的沙箱容器里使用。", "assetReadable": "模型可按需读取", diff --git a/public/avatars/README.md b/public/avatars/README.md new file mode 100644 index 0000000..ca8862f --- /dev/null +++ b/public/avatars/README.md @@ -0,0 +1,18 @@ +# 内置头像 + +数字员工的可选头像。每个是 96×96 的 SVG:圆角底 + 一个 lucide 线性字形,自带一套浅底深字的配色。 + +**为什么是 SVG 而不是图片**:站里整套 UI 都用 lucide 图标,同源字形放大后和界面 +其它图标是同一套笔画语言;矢量任意尺寸都清晰,单个几百字节,也不牵扯图片版权。 + +**颜色写在文件里**:用户选了哪张就用那张自己的颜色——那是他明确挑的,不该被主题 +覆盖掉。只有**没选头像**时才回落到品牌主色的首字占位(`AppAvatar`)。 +所以每个文件都必须自带底色与字形色,颜色掉了就是一格看不见的空白(有测试盯着)。 + +**为什么放 public/ 而不是 OSS**:它们随代码走,每个部署都一样,不该占对象存储、 +也不该在离线环境里因为拉不到 OSS 就空掉。用户上传的头像才进 OSS。 + +字形来自 [lucide](https://lucide.dev)(ISC License),与 `lucide-react` 依赖同源。 + +改动约定:文件名就是 `apps.avatar_url` 里存的那截路径(`/avatars/.svg`)。 +**不要重命名或删除已有文件**——用过它的数字员工会变成裂图。要换风格就改文件内容。 diff --git a/public/avatars/analyst.svg b/public/avatars/analyst.svg new file mode 100644 index 0000000..aa7c32b --- /dev/null +++ b/public/avatars/analyst.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/bot.svg b/public/avatars/bot.svg new file mode 100644 index 0000000..25daca3 --- /dev/null +++ b/public/avatars/bot.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/engineer.svg b/public/avatars/engineer.svg new file mode 100644 index 0000000..af1880a --- /dev/null +++ b/public/avatars/engineer.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/finance.svg b/public/avatars/finance.svg new file mode 100644 index 0000000..486e5dd --- /dev/null +++ b/public/avatars/finance.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/reviewer.svg b/public/avatars/reviewer.svg new file mode 100644 index 0000000..526cc8b --- /dev/null +++ b/public/avatars/reviewer.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/scheduler.svg b/public/avatars/scheduler.svg new file mode 100644 index 0000000..52a191f --- /dev/null +++ b/public/avatars/scheduler.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/support.svg b/public/avatars/support.svg new file mode 100644 index 0000000..a496327 --- /dev/null +++ b/public/avatars/support.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/writer.svg b/public/avatars/writer.svg new file mode 100644 index 0000000..bf35d63 --- /dev/null +++ b/public/avatars/writer.svg @@ -0,0 +1 @@ + diff --git a/test/appAvatar.test.ts b/test/appAvatar.test.ts new file mode 100644 index 0000000..8e4f9df --- /dev/null +++ b/test/appAvatar.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { + BUILTIN_AVATARS, + avatarFallbackText, + isBuiltinAvatar, + uniqueAvatarFilename, +} from "../lib/appAvatar.ts"; + +test("内置头像清单里的每个文件都真实存在", () => { + // 清单是手写的,文件在另一个目录:改名/删文件不会有任何编译期报错, + // 只会让选择器里出现一格裂图。这条把两边钉在一起。 + for (const url of BUILTIN_AVATARS) { + const file = path.join(process.cwd(), "public", url.replace(/^\//, "")); + assert.ok(fs.existsSync(file), `清单里有 ${url} 但 public 下没有这个文件`); + } +}); + +test("public/avatars 下的 svg 都进了清单", () => { + // 反方向:加了图却忘了登记,用户永远选不到它 + const dir = path.join(process.cwd(), "public", "avatars"); + const onDisk = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".svg")) + .map((f) => `/avatars/${f}`); + for (const url of onDisk) { + assert.ok( + (BUILTIN_AVATARS as readonly string[]).includes(url), + `${url} 在磁盘上但没登记进 BUILTIN_AVATARS` + ); + } +}); + +test("占位文字取首字符", () => { + assert.equal(avatarFallbackText("财经助理"), "财"); + assert.equal(avatarFallbackText("resume agent"), "R"); +}); + +test("占位文字:空名称不崩,代理对不切半个字", () => { + assert.equal(avatarFallbackText(""), "?"); + assert.equal(avatarFallbackText(null), "?"); + assert.equal(avatarFallbackText(" "), "?"); + // "🤖" 是代理对,按 UTF-16 下标取会拿到半个字符渲染成乱码方块 + assert.equal(avatarFallbackText("🤖 助手"), "🤖"); +}); + +test("内置头像必须自带颜色,否则就是一格看不见的空白", () => { + // 它们经 加载——拿不到页面的 CSS 变量,也没法被组件上色。颜色必须写在 + // 文件里:底色 + 字形 stroke。谁把颜色抽走(比如想改成跟主题走)都不会 + // 报错,只会让选择器里出现一格白板,而这种"看不见"最容易在自测时被当成没渲染。 + const dir = path.join(process.cwd(), "public", "avatars"); + const files = fs.readdirSync(dir).filter((x) => x.endsWith(".svg")); + assert.ok(files.length > 0, "public/avatars 下一个 svg 都没有"); + for (const f of files) { + const svg = fs.readFileSync(path.join(dir, f), "utf8"); + assert.match(svg, /]*fill="#[0-9a-f]{3,6}"/i, `${f} 没有底色`); + assert.match(svg, /stroke="#[0-9a-f]{3,6}"/i, `${f} 字形没有颜色`); + assert.ok(!svg.includes("currentColor"), `${f} 用了 currentColor,经 加载时取不到颜色`); + } +}); + +test("认得出内置头像与上传头像", () => { + assert.equal(isBuiltinAvatar("/avatars/bot.svg"), true); + assert.equal(isBuiltinAvatar("/api/oss/app-avatars/202608/x.png"), false); + assert.equal(isBuiltinAvatar(null), false); + assert.equal(isBuiltinAvatar(""), false); +}); + +test("上传文件名唯一化:保留扩展名、两次不重", () => { + // OSS 的 key 直接拿文件名拼,服务端不去重(实测同名两次 presign 同一个 key)。 + // 大家的头像都叫 avatar.png,不改名就是互相覆盖。 + const a = uniqueAvatarFilename("avatar.png"); + const b = uniqueAvatarFilename("avatar.png"); + assert.notEqual(a, b); + assert.ok(a.endsWith(".png"), a); +}); + +test("上传文件名唯一化:奇怪的名字不崩", () => { + assert.ok(uniqueAvatarFilename("头像.JPEG").endsWith(".jpeg")); // 中文名 + 大写后缀 + assert.ok(!uniqueAvatarFilename("noext").includes(".")); // 没后缀就不给后缀 + assert.ok(uniqueAvatarFilename("").length > 0); // 空名也要给得出名字 + // 「点很多」和「后缀超长」都不该被当成扩展名切出来 + assert.ok(uniqueAvatarFilename("a.b.c.png").endsWith(".png")); + assert.ok(!uniqueAvatarFilename("x.thisisnotanextension").includes(".")); +}); diff --git a/test/appTrigger.test.ts b/test/appTrigger.test.ts new file mode 100644 index 0000000..3cb6eec --- /dev/null +++ b/test/appTrigger.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { TRIGGER_LABEL_KEY, triggerLabel } from "../lib/appTrigger.ts"; + +// 假 t:直接回文案键,便于断言映射本身 +const t = ((k: string) => k) as unknown as (key: never) => string; + +test("底层值 → 文案键的映射", () => { + assert.equal(triggerLabel("Chat", t), "chatType"); + assert.equal(triggerLabel("Email", t), "emailType"); + assert.equal(triggerLabel("Custom", t), "customType"); +}); + +test("Subscription 显示为定时任务,但值本身不变", () => { + // 这条是整个改动的关键取舍:显示归到「定时任务」,存的值仍是 Subscription, + // 因为 isStreamApp 靠它决定要不要显示订阅源管理。 + assert.equal(triggerLabel("Subscription", t), "subscriptionType"); + assert.ok("Subscription" in TRIGGER_LABEL_KEY); +}); + +test("撤下的 Tool / Plugin 仍有文案,不显示空白", () => { + assert.equal(triggerLabel("Tool", t), "toolType"); + assert.equal(triggerLabel("Plugin", t), "pluginType"); +}); + +test("认不出的值原样显示", () => { + // 比显示空白强,也让"哪儿冒出个新值"看得见 + assert.equal(triggerLabel("SomethingNew", t), "SomethingNew"); + assert.equal(triggerLabel("", t), ""); +}); diff --git a/types/workflow.ts b/types/workflow.ts index e6f95bd..bfe8551 100644 --- a/types/workflow.ts +++ b/types/workflow.ts @@ -40,8 +40,9 @@ export interface AppWithWorkflow { id: number; name: string; description: string; - app_type: "Chat" | "Tool" | "Plugin" | "Subscription" | "Custom"; - platform: "Web" | "Wechat" | "Feishu" | "iOS" | "Android"; + // 触发方式(列名沿用 app_type)。Tool / Plugin 不再是可选项,仅兼容存量数据。 + app_type: "Chat" | "Subscription" | "Email" | "Custom" | "Tool" | "Plugin"; + platform: "Web" | "Wechat" | "Plugin" | "Feishu" | "iOS" | "Android"; user_id: number; ai_model: string; prompt_id: number | null;