fix search api
This commit is contained in:
parent
81bf2a2618
commit
1168d49178
10
Makefile
10
Makefile
|
|
@ -8,7 +8,7 @@ PORT ?= 3000
|
|||
PM2 := $(shell command -v pm2 2>/dev/null)
|
||||
|
||||
.PHONY: help init db-init install build up start dev stop restart status logs down \
|
||||
playwright-setup save
|
||||
playwright-setup playwright-deps save
|
||||
|
||||
help:
|
||||
@echo "巡樓 Haixun — 常用指令"
|
||||
|
|
@ -32,6 +32,7 @@ help:
|
|||
@echo " 其他"
|
||||
@echo " make build next build"
|
||||
@echo " make playwright-setup 安裝 Chromium 與 Playwright 依賴"
|
||||
@echo " make playwright-deps 安裝 Playwright 系統函式庫(apt)"
|
||||
@echo ""
|
||||
@echo " 環境變數:PORT=$(PORT)(預設 3000)"
|
||||
|
||||
|
|
@ -91,5 +92,12 @@ save: check-pm2
|
|||
@pm2 save
|
||||
@echo "PM2 程序列表已儲存(搭配 pm2 startup 可開機自啟)"
|
||||
|
||||
playwright-deps:
|
||||
@echo "安裝 Playwright 系統函式庫..."
|
||||
@sudo apt-get install -y libatk1.0-0 libatk-bridge2.0-0t64 libcups2t64 \
|
||||
libdrm2 libdbus-1-3 libxkbcommon-x11-0 libxcomposite1 libxdamage1 \
|
||||
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64
|
||||
@echo "Playwright 系統函式庫安裝完成"
|
||||
|
||||
playwright-setup:
|
||||
@cd "$(ROOT)" && npm run playwright:setup
|
||||
|
|
@ -760,6 +760,7 @@ export default function TopicDetailPage() {
|
|||
onToggle={toggleTag}
|
||||
onSelectAllAccounts={selectAllAccountTags}
|
||||
hideAccounts={isPlacementGoal(topic.topicGoal)}
|
||||
researchMap={researchMap}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export async function POST(request: Request) {
|
|||
include: {
|
||||
topic: true,
|
||||
items: {
|
||||
where: { qualityTier: { not: "EXCLUDE" } },
|
||||
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
||||
take: body.limit ?? 8,
|
||||
include: { replies: { orderBy: { likeCount: "desc" }, take: 5 } },
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
SIMILAR_ACCOUNT_CONFIDENCE_LABELS,
|
||||
SIMILAR_ACCOUNT_SOURCE_LABELS,
|
||||
threadsProfileUrl,
|
||||
type ResearchMap,
|
||||
|
|
@ -177,6 +178,16 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
|
|||
{accounts.map((a) => {
|
||||
const profileUrl = a.profileUrl ?? threadsProfileUrl(a.username);
|
||||
const sourceLabel = a.source ? SIMILAR_ACCOUNT_SOURCE_LABELS[a.source] : null;
|
||||
const confidenceLabel = a.confidence ? SIMILAR_ACCOUNT_CONFIDENCE_LABELS[a.confidence] : null;
|
||||
const confidenceColor =
|
||||
a.confidence === "high"
|
||||
? "bg-emerald-500/10 text-emerald-600 border-emerald-200"
|
||||
: a.confidence === "medium"
|
||||
? "bg-amber-500/10 text-amber-600 border-amber-200"
|
||||
: "bg-muted text-muted-foreground border-border";
|
||||
const daysSinceActive = a.lastActiveAt
|
||||
? Math.floor((Date.now() - new Date(a.lastActiveAt).getTime()) / 86400000)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={a.username}
|
||||
|
|
@ -197,12 +208,22 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
|
|||
@{a.username}
|
||||
</span>
|
||||
)}
|
||||
{confidenceLabel && (
|
||||
<Badge variant="outline" className={cn("px-1.5 py-0 text-[9px]", confidenceColor)}>
|
||||
{confidenceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{sourceLabel && (
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[9px]">
|
||||
{sourceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{daysSinceActive !== null && (
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{daysSinceActive <= 1 ? "最近活躍" : `${daysSinceActive} 天前活躍`}
|
||||
</p>
|
||||
)}
|
||||
{a.postUrl ? (
|
||||
<a
|
||||
href={a.postUrl}
|
||||
|
|
@ -224,7 +245,7 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
|
|||
</div>
|
||||
) : (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
尚未找到相似帳號,可重試分析或在微調面板手動加入 @帳號
|
||||
尚未找到相似帳號(沒有高品質也無中低品質候選),可重試分析或在微調面板手動加入 @帳號
|
||||
</p>
|
||||
)}
|
||||
</MapBlock>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Check } from "lucide-react";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SuggestedTag } from "@/lib/types/research";
|
||||
import { SIMILAR_ACCOUNT_CONFIDENCE_LABELS, type ResearchMap, type SuggestedTag } from "@/lib/types/research";
|
||||
|
||||
interface SuggestedTagsPickerProps {
|
||||
tags: SuggestedTag[];
|
||||
|
|
@ -13,17 +13,28 @@ interface SuggestedTagsPickerProps {
|
|||
onSelectAllAccounts?: () => void;
|
||||
/** 置入模式不顯示相似帳號標籤 */
|
||||
hideAccounts?: boolean;
|
||||
researchMap?: ResearchMap | null;
|
||||
}
|
||||
|
||||
function TagRow({
|
||||
item,
|
||||
isSelected,
|
||||
onToggle,
|
||||
confidence,
|
||||
}: {
|
||||
item: SuggestedTag;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
confidence?: "high" | "medium" | "low" | null;
|
||||
}) {
|
||||
const confidenceLabel = confidence ? SIMILAR_ACCOUNT_CONFIDENCE_LABELS[confidence] : null;
|
||||
const confidenceColor =
|
||||
confidence === "high"
|
||||
? "bg-emerald-500/10 text-emerald-600 border-emerald-200"
|
||||
: confidence === "medium"
|
||||
? "bg-amber-500/10 text-amber-600 border-amber-200"
|
||||
: "bg-muted text-muted-foreground border-border";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -46,6 +57,11 @@ function TagRow({
|
|||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-[13px] font-medium text-foreground">{item.tag}</p>
|
||||
{confidenceLabel && (
|
||||
<Badge variant="outline" className={cn("px-1.5 py-0 text-[10px]", confidenceColor)}>
|
||||
{confidenceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{item.searchType && (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
{item.searchType}
|
||||
|
|
@ -71,7 +87,11 @@ export function SuggestedTagsPicker({
|
|||
onToggle,
|
||||
onSelectAllAccounts,
|
||||
hideAccounts = false,
|
||||
researchMap,
|
||||
}: SuggestedTagsPickerProps) {
|
||||
const accountConfidence = new Map(
|
||||
(researchMap?.similarAccounts ?? []).map((a) => [`@${a.username.toLowerCase()}`, a.confidence])
|
||||
);
|
||||
const accountTags = hideAccounts
|
||||
? []
|
||||
: tags.filter((t) => t.searchType === "帳號" || t.tag.startsWith("@"));
|
||||
|
|
@ -101,6 +121,7 @@ export function SuggestedTagsPicker({
|
|||
item={item}
|
||||
isSelected={selected.includes(item.tag)}
|
||||
onToggle={() => onToggle(item.tag)}
|
||||
confidence={accountConfidence.get(item.tag.toLowerCase())}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { generateObject, generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ProviderApiKeys } from "./keys";
|
||||
import type { MatrixRow, ResearchMap } from "@/lib/types/research";
|
||||
import { withAgentSystem } from "./agent";
|
||||
import { buildPersonaPromptBlock } from "./persona";
|
||||
import { getModel } from "./provider";
|
||||
|
|
@ -16,7 +17,6 @@ import {
|
|||
} from "./coerce-matrix";
|
||||
import { HASHTAG_USER_REMINDER, HASHTAG_WRITING_RULES } from "./hashtag-rules";
|
||||
import { sanitizePromptText, THREADS_MAX_CHARS } from "@/lib/utils";
|
||||
import type { MatrixRow, ResearchMap } from "@/lib/types/research";
|
||||
|
||||
const matrixSchema = z.object({
|
||||
rows: z.array(
|
||||
|
|
@ -33,9 +33,10 @@ const matrixSchema = z.object({
|
|||
),
|
||||
});
|
||||
|
||||
/** 非 OpenCode Go 但 structured output 不穩的模型 */
|
||||
const TEXT_FIRST_MODELS = new Set(["grok-3", "grok-3-fast"]);
|
||||
|
||||
const BATCH_COUNT = 3;
|
||||
|
||||
export interface GenerateMatrixInput {
|
||||
topicLabel: string;
|
||||
query: string;
|
||||
|
|
@ -58,8 +59,52 @@ export interface GenerateMatrixInput {
|
|||
}>;
|
||||
}
|
||||
|
||||
function distributePosts<T>(posts: T[], n: number): T[][] {
|
||||
const batches: T[][] = Array.from({ length: n }, () => []);
|
||||
posts.forEach((post, i) => batches[i % n].push(post));
|
||||
return batches;
|
||||
}
|
||||
|
||||
function buildMaterialsBlock(posts: GenerateMatrixInput["posts"], defaultQuery: string): string {
|
||||
return posts
|
||||
.map((post, i) => {
|
||||
const repliesBlock =
|
||||
post.replies && post.replies.length > 0
|
||||
? `\n 留言:${post.replies
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(r) =>
|
||||
`@${sanitizePromptText(r.authorName) || "匿名"}:${sanitizePromptText(r.text).slice(0, 80)}`
|
||||
)
|
||||
.join(" | ")}`
|
||||
: "";
|
||||
|
||||
return `${i + 1}. [${sanitizePromptText(post.searchTag) || sanitizePromptText(defaultQuery)}] @${sanitizePromptText(post.authorName) || "匿名"}(${post.likeCount ?? 0}讚)
|
||||
${sanitizePromptText(post.text).slice(0, 300)}
|
||||
連結:${sanitizePromptText(post.permalink) || "無"}${repliesBlock}${post.qualityReason ? `\n 品質說明:${sanitizePromptText(post.qualityReason)}` : ""}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildResearchBlock(researchMap?: ResearchMap | null): string {
|
||||
if (!researchMap) return "";
|
||||
|
||||
const similarAccounts = (researchMap.similarAccounts ?? [])
|
||||
.slice(0, 10)
|
||||
.map((a) => ` @${sanitizePromptText(a.username)}${a.reason ? `(${sanitizePromptText(a.reason)})` : ""}`)
|
||||
.join("\n");
|
||||
|
||||
return `
|
||||
受眾:${sanitizePromptText(researchMap.audienceSummary)}
|
||||
內容目標:${sanitizePromptText(researchMap.contentGoal)}
|
||||
想回答的問題:${researchMap.questions.map(sanitizePromptText).join("、")}
|
||||
內容支柱:${researchMap.pillars.map(sanitizePromptText).join("、")}
|
||||
${similarAccounts ? `同領域參考帳號:\n${similarAccounts}` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSystemPrompt(persona?: string | null) {
|
||||
return withAgentSystem(`你是 Threads 內容企劃師。根據篩選後的優質素材,產出「內容矩陣」——一週可發的貼文企劃表。
|
||||
return withAgentSystem(`你是 Threads 內容企劃師。根據參考素材(含優質與中等品質),產出「內容矩陣」——一週可發的貼文企劃表。
|
||||
|
||||
${buildPersonaPromptBlock(persona)}
|
||||
|
||||
|
|
@ -72,19 +117,29 @@ ${HASHTAG_WRITING_RULES}
|
|||
- 每篇對應一個 searchTag(主題標籤)
|
||||
- sortOrder 從 1 開始遞增
|
||||
- 繁體中文台灣用語
|
||||
- 知識型貼文:不寫未查證的醫療/數據斷言,rationale 標註需網路查證的關鍵句`);
|
||||
- 知識型貼文:不寫未查證的醫療/數據斷言,rationale 標註需網路查證的關鍵句
|
||||
- 部分素材附有[品質說明]:品質較低的仍有參考價值(角度、切點可用),產出時不要直接沿用其內容`);
|
||||
}
|
||||
|
||||
function buildUserPrompt(input: GenerateMatrixInput, researchBlock: string, materials: string) {
|
||||
function buildBatchPrompt(
|
||||
input: GenerateMatrixInput,
|
||||
researchBlock: string,
|
||||
materials: string,
|
||||
batchIndex: number,
|
||||
totalBatches: number,
|
||||
thisBatchCount: number
|
||||
) {
|
||||
return `主題:${sanitizePromptText(input.topicLabel)}
|
||||
種子關鍵字:${sanitizePromptText(input.query)}
|
||||
${input.brief ? `Brief:${sanitizePromptText(input.brief)}` : ""}
|
||||
${researchBlock}
|
||||
|
||||
優質參考素材:
|
||||
【批次 ${batchIndex + 1}/${totalBatches}】
|
||||
以下為第 ${batchIndex + 1} 批素材(共 ${materials.split("\n\n").length} 篇),請產出 ${thisBatchCount} 篇不同切角,注意與其他批次做出區隔,避免重複。
|
||||
|
||||
參考素材:
|
||||
${materials}
|
||||
|
||||
請產出 ${input.count} 篇內容矩陣。每篇涵蓋不同切角,避免重複。
|
||||
寫作提醒:矩陣裡的 text 必須像創作者親筆,有代表句範例時語感要向範例靠攏。${HASHTAG_USER_REMINDER}`;
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +173,22 @@ function normalizeRows(rows: MatrixRow[]): MatrixRow[] {
|
|||
}));
|
||||
}
|
||||
|
||||
function mergeBatches(batches: MatrixRow[][]): MatrixRow[] {
|
||||
const all = batches.flat();
|
||||
const seen = new Set<string>();
|
||||
const merged: MatrixRow[] = [];
|
||||
|
||||
for (const row of all.sort((a, b) => a.sortOrder - b.sortOrder)) {
|
||||
const key = row.angle.slice(0, 30).toLowerCase().replace(/\s+/g, "");
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
merged.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return merged.map((row, i) => ({ ...row, sortOrder: i + 1 }));
|
||||
}
|
||||
|
||||
async function generateWithText(
|
||||
model: ReturnType<typeof getModel>,
|
||||
system: string,
|
||||
|
|
@ -157,76 +228,46 @@ async function generateWithObject(
|
|||
return normalizeRows(object.rows);
|
||||
}
|
||||
|
||||
export async function generateContentMatrix(input: GenerateMatrixInput): Promise<MatrixRow[]> {
|
||||
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
|
||||
|
||||
const researchBlock = input.researchMap
|
||||
? `
|
||||
受眾:${sanitizePromptText(input.researchMap.audienceSummary)}
|
||||
內容目標:${sanitizePromptText(input.researchMap.contentGoal)}
|
||||
想回答的問題:${input.researchMap.questions.map(sanitizePromptText).join("、")}
|
||||
內容支柱:${input.researchMap.pillars.map(sanitizePromptText).join("、")}
|
||||
`
|
||||
: "";
|
||||
|
||||
const materials = input.posts
|
||||
.map((post, i) => {
|
||||
const repliesBlock =
|
||||
post.replies && post.replies.length > 0
|
||||
? `\n 留言:${post.replies
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(r) =>
|
||||
`@${sanitizePromptText(r.authorName) || "匿名"}:${sanitizePromptText(r.text).slice(0, 80)}`
|
||||
)
|
||||
.join(" | ")}`
|
||||
: "";
|
||||
|
||||
return `${i + 1}. [${sanitizePromptText(post.searchTag) || sanitizePromptText(input.query)}] @${sanitizePromptText(post.authorName) || "匿名"}(${post.likeCount ?? 0}讚)
|
||||
${sanitizePromptText(post.text).slice(0, 300)}
|
||||
連結:${sanitizePromptText(post.permalink) || "無"}${repliesBlock}${post.qualityReason ? `\n 品質說明:${sanitizePromptText(post.qualityReason)}` : ""}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const system = buildSystemPrompt(input.persona);
|
||||
const prompt = buildUserPrompt(input, researchBlock, materials);
|
||||
const fallback = { query: input.query, count: input.count };
|
||||
|
||||
const preferText =
|
||||
prefersOpenCodeTextFirst(input.aiProvider, input.aiModel) ||
|
||||
TEXT_FIRST_MODELS.has(input.aiModel);
|
||||
|
||||
let rows: MatrixRow[] | null = null;
|
||||
let lastError: unknown;
|
||||
|
||||
async function attemptBatch(
|
||||
model: ReturnType<typeof getModel>,
|
||||
system: string,
|
||||
prompt: string,
|
||||
fallback: { query: string; count: number },
|
||||
provider: string,
|
||||
modelId: string,
|
||||
preferText: boolean
|
||||
): Promise<MatrixRow[]> {
|
||||
const attempts: Array<() => Promise<MatrixRow[]>> = preferText
|
||||
? [
|
||||
() => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel),
|
||||
() => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel),
|
||||
() => generateWithText(model, system, prompt, fallback, provider, modelId),
|
||||
() => generateWithObject(model, system, prompt, fallback, provider, modelId),
|
||||
() =>
|
||||
generateWithText(
|
||||
model,
|
||||
system,
|
||||
`${prompt}\n\n上次格式不完整,請務必補齊 ${input.count} 篇 rows。`,
|
||||
`${prompt}\n\n上次格式不完整,請務必補齊 ${fallback.count} 篇 rows。`,
|
||||
fallback,
|
||||
input.aiProvider,
|
||||
input.aiModel
|
||||
provider,
|
||||
modelId
|
||||
),
|
||||
]
|
||||
: [
|
||||
() => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel),
|
||||
() => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel),
|
||||
() => generateWithObject(model, system, prompt, fallback, provider, modelId),
|
||||
() => generateWithText(model, system, prompt, fallback, provider, modelId),
|
||||
() =>
|
||||
generateWithText(
|
||||
model,
|
||||
system,
|
||||
`${prompt}\n\n上次格式不完整,請務必補齊 ${input.count} 篇 rows。`,
|
||||
`${prompt}\n\n上次格式不完整,請務必補齊 ${fallback.count} 篇 rows。`,
|
||||
fallback,
|
||||
input.aiProvider,
|
||||
input.aiModel
|
||||
provider,
|
||||
modelId
|
||||
),
|
||||
];
|
||||
|
||||
let rows: MatrixRow[] | null = null;
|
||||
let lastError: unknown;
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
rows = await attempt();
|
||||
|
|
@ -241,4 +282,33 @@ export async function generateContentMatrix(input: GenerateMatrixInput): Promise
|
|||
}
|
||||
|
||||
return normalizeRows(rows);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateContentMatrix(input: GenerateMatrixInput): Promise<MatrixRow[]> {
|
||||
if (input.posts.length === 0) {
|
||||
throw new Error("此海巡沒有素材,請重新海巡");
|
||||
}
|
||||
|
||||
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
|
||||
const researchBlock = buildResearchBlock(input.researchMap);
|
||||
const system = buildSystemPrompt(input.persona);
|
||||
const preferText =
|
||||
prefersOpenCodeTextFirst(input.aiProvider, input.aiModel) ||
|
||||
TEXT_FIRST_MODELS.has(input.aiModel);
|
||||
|
||||
const batches = distributePosts(input.posts, BATCH_COUNT);
|
||||
const perBatch = Math.ceil(input.count / BATCH_COUNT);
|
||||
|
||||
const results = await Promise.all(
|
||||
batches.map((batchPosts, batchIndex) => {
|
||||
if (batchPosts.length === 0) return Promise.resolve([] as MatrixRow[]);
|
||||
const materials = buildMaterialsBlock(batchPosts, input.query);
|
||||
const prompt = buildBatchPrompt(input, researchBlock, materials, batchIndex, BATCH_COUNT, perBatch);
|
||||
const fallback = { query: input.query, count: perBatch };
|
||||
return attemptBatch(model, system, prompt, fallback, input.aiProvider, input.aiModel, preferText);
|
||||
})
|
||||
);
|
||||
|
||||
const merged = mergeBatches(results);
|
||||
return normalizeRows(merged).slice(0, input.count);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ const researchMapSchema = z.object({
|
|||
postUrl: z.string().optional(),
|
||||
profileUrl: z.string().optional(),
|
||||
source: z.enum(["web", "threads", "scan"]).optional(),
|
||||
confidence: z.enum(["high", "medium", "low"]).optional(),
|
||||
lastActiveAt: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
|
|
@ -105,7 +107,11 @@ export async function refineResearchMap(input: RefineResearchMapInput): Promise<
|
|||
- 精準執行用戶意圖,不要整份重寫除非用戶明確要求
|
||||
- 維持 Threads 搜尋標籤品質(短詞優先、像真人會搜的字、扣緊種子關鍵字)
|
||||
- 用戶沒要求刪的標籤盡量保留;複合主題不可拆成過寬單字(如寵物洗毛精≠只搜寵物或洗碗精)
|
||||
- reply 簡潔說明變更重點,繁體中文台灣用語`;
|
||||
- reply 簡潔說明變更重點,繁體中文台灣用語
|
||||
|
||||
回傳 JSON 格式,須包含兩個欄位:
|
||||
1. reply:字串,2~4 句繁體中文說明本次變更重點
|
||||
2. researchMap:研究地圖物件,格式與「目前研究地圖草稿」相同`;
|
||||
|
||||
const prompt = buildRefineUserPrompt(input);
|
||||
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ async function runOutreachTask(
|
|||
const candidates = await prisma.scanItem.findMany({
|
||||
where: {
|
||||
externalId: { not: null },
|
||||
qualityTier: { not: "EXCLUDE" },
|
||||
OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }],
|
||||
outreachTargets: { none: {} },
|
||||
scan: { accountId, scanGoal: "placement", createdAt: { gte: since } },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function mapBraveItems(
|
|||
threadsOnly: boolean
|
||||
): SearchResult[] {
|
||||
return items
|
||||
.filter((item) => item.url && (!threadsOnly || /threads\.com/i.test(item.url)))
|
||||
.filter((item) => item.url && (!threadsOnly || /threads\.(?:com|net)/i.test(item.url)))
|
||||
.slice(0, limit)
|
||||
.map((item) => ({
|
||||
title: item.title ?? "",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function getSearchConfig() {
|
|||
process.env.BRAVE_SEARCH_BASE_URL?.trim() ||
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
dailyLimit: envInt("BRAVE_DAILY_LIMIT", 30),
|
||||
resultLimit: envInt("BRAVE_RESULT_LIMIT", 10),
|
||||
resultLimit: envInt("BRAVE_RESULT_LIMIT", 20),
|
||||
cacheTtlMs: parseDurationMs(process.env.BRAVE_CACHE_TTL, 4 * 3_600_000),
|
||||
scanMaxQueries: envInt("SCAN_BRAVE_MAX_QUERIES", 8),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
normalizeThreadsPostUrl,
|
||||
normalizeUsername,
|
||||
threadsProfileUrl,
|
||||
type AccountConfidence,
|
||||
type SimilarAccount,
|
||||
} from "@/lib/types/research";
|
||||
|
||||
|
|
@ -102,6 +103,16 @@ function buildDiscoverAnchor(ctx: DiscoverContext): DiscoverAnchor {
|
|||
return { ...base, pillars, specificTags };
|
||||
}
|
||||
|
||||
function assignConfidence(candidate: AccountCandidate): AccountConfidence {
|
||||
if (candidate.score > 20 && (candidate.source === "threads" || candidate.source === "scan")) {
|
||||
return "high";
|
||||
}
|
||||
if (candidate.score > 10 || (candidate.aiScore ?? 0) > 0.5) {
|
||||
return "medium";
|
||||
}
|
||||
return "low";
|
||||
}
|
||||
|
||||
function extractTagsFromText(text: string): string[] {
|
||||
const found = new Set<string>();
|
||||
for (const match of text.match(/#[\w\u4e00-\u9fff]{2,24}/g) ?? []) {
|
||||
|
|
@ -136,7 +147,6 @@ function addCandidate(
|
|||
if (!isValidUsername(clean)) return;
|
||||
|
||||
const relevance = scoreTopicRelevance(params.reason, params.anchor);
|
||||
if (relevance < 3) return;
|
||||
|
||||
const key = clean.toLowerCase();
|
||||
const existing = map.get(key);
|
||||
|
|
@ -167,10 +177,13 @@ function addCandidate(
|
|||
function buildWebSearchQueries(anchor: DiscoverAnchor, brief?: string | null): string[] {
|
||||
const quoted = `"${anchor.corePhrase}"`;
|
||||
const queries = [
|
||||
`site:threads.com ${quoted}`,
|
||||
`site:threads.net ${quoted}`,
|
||||
`threads ${quoted} 帳號`,
|
||||
`threads ${quoted} 創作者`,
|
||||
`site:threads.net ${quoted} 推薦`,
|
||||
`site:threads.net ${quoted} 心得`,
|
||||
`${quoted} 創作者 threads`,
|
||||
`"${anchor.corePhrase}" site:threads.net`,
|
||||
];
|
||||
|
||||
const briefHint = brief?.trim().slice(0, 24) ?? "";
|
||||
|
|
@ -180,7 +193,8 @@ function buildWebSearchQueries(anchor: DiscoverAnchor, brief?: string | null): s
|
|||
|
||||
for (const pillar of anchor.pillars.slice(0, 2)) {
|
||||
if (pillar.length >= 4 && scoreTopicRelevance(pillar, anchor) >= 6) {
|
||||
queries.push(`site:threads.com "${pillar}"`);
|
||||
queries.push(`site:threads.net "${pillar}"`);
|
||||
queries.push(`threads "${pillar}" 推薦`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +224,7 @@ async function discoverFromWebSearch(
|
|||
const map = new Map<string, AccountCandidate>();
|
||||
const queries = buildWebSearchQueries(anchor, brief);
|
||||
|
||||
const perQueryLimit = 8;
|
||||
const perQueryLimit = 15;
|
||||
const results = await Promise.all(
|
||||
queries.map((q) =>
|
||||
searchWebThorough(q, perQueryLimit, {
|
||||
|
|
@ -225,7 +239,6 @@ async function discoverFromWebSearch(
|
|||
for (const item of batch.results) {
|
||||
const blob = `${item.link} ${item.title} ${item.snippet}`;
|
||||
const relevance = scoreTopicRelevance(blob, anchor);
|
||||
if (relevance < 3) continue;
|
||||
|
||||
const tags = extractTagsFromText(blob);
|
||||
for (const username of extractUsernamesFromText(blob)) {
|
||||
|
|
@ -267,7 +280,6 @@ async function discoverFromThreadsSearch(
|
|||
if (!post.authorName) continue;
|
||||
const postText = post.text.trim();
|
||||
const relevance = scoreTopicRelevance(postText, anchor);
|
||||
if (relevance < 3) continue;
|
||||
|
||||
addCandidate(map, post.authorName, {
|
||||
reason:
|
||||
|
|
@ -290,15 +302,13 @@ async function discoverFromThreadsSearch(
|
|||
}
|
||||
|
||||
function rankCandidates(candidates: AccountCandidate[]): AccountCandidate[] {
|
||||
return candidates
|
||||
.filter((c) => c.relevance >= 3)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score +
|
||||
b.relevance * 2 +
|
||||
(b.aiScore ?? 0) * 3 -
|
||||
(a.score + a.relevance * 2 + (a.aiScore ?? 0) * 3)
|
||||
);
|
||||
return candidates.sort(
|
||||
(a, b) =>
|
||||
b.score +
|
||||
b.relevance * 2 +
|
||||
(b.aiScore ?? 0) * 3 -
|
||||
(a.score + a.relevance * 2 + (a.aiScore ?? 0) * 3)
|
||||
);
|
||||
}
|
||||
|
||||
async function applyAiRelevanceFilter(
|
||||
|
|
@ -334,18 +344,24 @@ async function applyAiRelevanceFilter(
|
|||
apiKeys: ai.apiKeys,
|
||||
});
|
||||
|
||||
return candidates
|
||||
.map((c) => {
|
||||
const verdict = verdicts.get(c.username.toLowerCase());
|
||||
if (!verdict) return c;
|
||||
return {
|
||||
...c,
|
||||
aiScore: verdict.score,
|
||||
aiReason: verdict.reason,
|
||||
relevance: verdict.relevant ? c.relevance + Math.round(verdict.score * 4) : -100,
|
||||
};
|
||||
})
|
||||
.filter((c) => c.relevance >= 3);
|
||||
return candidates.map((c) => {
|
||||
const verdict = verdicts.get(c.username.toLowerCase());
|
||||
if (!verdict) return c;
|
||||
return {
|
||||
...c,
|
||||
aiScore: verdict.score,
|
||||
aiReason: verdict.reason,
|
||||
relevance: verdict.relevant
|
||||
? c.relevance + Math.round(verdict.score * 4)
|
||||
: c.relevance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function verifyAccountConsistency(
|
||||
candidates: AccountCandidate[]
|
||||
): AccountCandidate[] {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function toSimilarAccounts(candidates: AccountCandidate[], limit: number): SimilarAccount[] {
|
||||
|
|
@ -355,10 +371,10 @@ function toSimilarAccounts(candidates: AccountCandidate[], limit: number): Simil
|
|||
source: c.source,
|
||||
profileUrl: threadsProfileUrl(c.username) ?? undefined,
|
||||
postUrl: c.postUrl,
|
||||
confidence: assignConfidence(c),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 優先瀏覽器爬蟲,不足時 Brave 網搜補充;不讓 AI 捏造 username */
|
||||
export async function discoverSimilarAccounts(params: {
|
||||
label: string;
|
||||
query: string;
|
||||
|
|
@ -378,7 +394,6 @@ export async function discoverSimilarAccounts(params: {
|
|||
|
||||
const merged = new Map<string, AccountCandidate>();
|
||||
|
||||
// 1. 瀏覽器爬蟲(Threads 站內搜尋)
|
||||
if (params.storageState) {
|
||||
const threadsCandidates = await discoverFromThreadsSearch(
|
||||
params.storageState,
|
||||
|
|
@ -390,21 +405,17 @@ export async function discoverSimilarAccounts(params: {
|
|||
}
|
||||
}
|
||||
|
||||
// 2. Brave 網搜補充(結果不足時)
|
||||
const rankedSoFar = rankCandidates(Array.from(merged.values()));
|
||||
if (rankedSoFar.length < 3) {
|
||||
const webCandidates = await discoverFromWebSearch(anchor, params.brief);
|
||||
for (const c of webCandidates) {
|
||||
const key = c.username.toLowerCase();
|
||||
const existing = merged.get(key);
|
||||
if (existing) {
|
||||
existing.score += c.score;
|
||||
existing.relevance = Math.max(existing.relevance, c.relevance);
|
||||
if (c.postUrl) existing.postUrl = c.postUrl;
|
||||
if (c.reason.length > existing.reason.length) existing.reason = c.reason;
|
||||
} else {
|
||||
merged.set(key, c);
|
||||
}
|
||||
const webCandidates = await discoverFromWebSearch(anchor, params.brief);
|
||||
for (const c of webCandidates) {
|
||||
const key = c.username.toLowerCase();
|
||||
const existing = merged.get(key);
|
||||
if (existing) {
|
||||
existing.score += c.score;
|
||||
existing.relevance = Math.max(existing.relevance, c.relevance);
|
||||
if (c.postUrl) existing.postUrl = c.postUrl;
|
||||
if (c.reason.length > existing.reason.length) existing.reason = c.reason;
|
||||
} else {
|
||||
merged.set(key, c);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -419,5 +430,8 @@ export async function discoverSimilarAccounts(params: {
|
|||
sorted = rankCandidates(sorted);
|
||||
}
|
||||
|
||||
sorted = verifyAccountConsistency(sorted);
|
||||
sorted = rankCandidates(sorted);
|
||||
|
||||
return toSimilarAccounts(sorted, limit);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export async function generateDraftsForScan(scanId: string) {
|
|||
include: {
|
||||
topic: true,
|
||||
items: {
|
||||
where: { qualityTier: { not: "EXCLUDE" } },
|
||||
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
||||
take: 8,
|
||||
include: {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ export async function generateMatrixForScan(scanId: string, count?: number) {
|
|||
include: {
|
||||
topic: true,
|
||||
items: {
|
||||
where: { qualityTier: { not: "EXCLUDE" } },
|
||||
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
||||
take: 12,
|
||||
take: 24,
|
||||
include: {
|
||||
replies: { orderBy: { likeCount: "desc" }, take: 5 },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ function buildPlacementKeywordQueries(tag: string, meta?: TagSearchMeta): string
|
|||
meta?.searchIntent === "求助" ||
|
||||
meta?.searchIntent === "痛點";
|
||||
const intent = isNeedTag ? "求推薦" : "請問";
|
||||
return [`site:threads.com "${tag}" ${intent} after:${after}`];
|
||||
return [`site:threads.net "${tag}" ${intent} after:${after}`];
|
||||
}
|
||||
|
||||
function resolveBraveQueryCap(placementMode: boolean): number {
|
||||
|
|
@ -102,7 +102,7 @@ function buildKeywordQueries(
|
|||
meta?: TagSearchMeta
|
||||
): string[] {
|
||||
if (placementMode) return buildPlacementKeywordQueries(tag, meta);
|
||||
return [`site:threads.com "${tag}"`, `site:threads.net "${tag}"`];
|
||||
return [`site:threads.net "${tag}"`];
|
||||
}
|
||||
|
||||
function passesPlacementWebFilter(
|
||||
|
|
@ -230,7 +230,7 @@ export async function discoverPostsViaWebSearch(
|
|||
tags: string[],
|
||||
options?: WebDiscoverOptions
|
||||
): Promise<WebDiscoveredPost[]> {
|
||||
const perQueryLimit = options?.perQueryLimit ?? 10;
|
||||
const perQueryLimit = options?.perQueryLimit ?? 15;
|
||||
const placementMode = options?.placementMode ?? false;
|
||||
const contentBand = options?.contentBand;
|
||||
const concurrency = options?.concurrency ?? 2;
|
||||
|
|
@ -273,7 +273,7 @@ export async function discoverPostsViaWebSearch(
|
|||
for (const phrase of bandPhrases) {
|
||||
jobs.push({
|
||||
tag: phrase,
|
||||
query: `site:threads.com "${phrase}" 求推薦 after:${after}`,
|
||||
query: `site:threads.net "${phrase}" 求推薦 after:${after}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -355,7 +355,7 @@ export async function discoverPostsFromSimilarAccounts(
|
|||
const placementMode = options?.placementMode ?? false;
|
||||
const braveOptions = resolveBraveSearchOptions(placementMode, options?.keywordPriority);
|
||||
const useBrave = braveOptions.priority === "high";
|
||||
const perAccountLimit = options?.perAccountLimit ?? 10;
|
||||
const perAccountLimit = options?.perAccountLimit ?? 15;
|
||||
const seen = new Set<string>();
|
||||
const posts: WebDiscoveredPost[] = [];
|
||||
|
||||
|
|
@ -369,12 +369,11 @@ export async function discoverPostsFromSimilarAccounts(
|
|||
const after = placementMode ? ` after:${formatGoogleAfterDate(PLACEMENT_WEB_SEARCH_MAX_AGE_DAYS)}` : "";
|
||||
const queries = placementMode
|
||||
? [
|
||||
`site:threads.com/@${username} 求推薦${after}`,
|
||||
`site:threads.com/@${username} 請益${after}`,
|
||||
`site:threads.com/@${username}${after}`,
|
||||
`site:threads.net/@${username} 求推薦${after}`,
|
||||
`site:threads.net/@${username} 請益${after}`,
|
||||
`site:threads.net/@${username}${after}`,
|
||||
]
|
||||
: [`site:threads.com/@${username}`, `site:threads.net/@${username}`];
|
||||
: [`site:threads.net/@${username}`];
|
||||
|
||||
if (!useBrave) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { getRepliesParallel } from "@/lib/threads-browser/replies";
|
|||
import { keywordSearchViaThreadsApi } from "@/lib/threads-api";
|
||||
import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials";
|
||||
import { computePlacementScore, type RankedPost } from "@/lib/ranking";
|
||||
import { parseResearchMap, parseSelectedTags } from "@/lib/types/research";
|
||||
import { parseResearchMap, parseSelectedTags, threadsProfileUrl, type SimilarAccount } from "@/lib/types/research";
|
||||
import { humanDelay } from "@/lib/utils";
|
||||
import { runWithConcurrency } from "@/lib/utils/concurrency";
|
||||
import {
|
||||
|
|
@ -255,7 +255,7 @@ export async function runScanForTopic(
|
|||
const accountWebTargets = [...accountTargets.values()];
|
||||
const [keywordWebPosts, accountWebPosts] = await Promise.all([
|
||||
discoverPostsViaWebSearch(webSearchTags, {
|
||||
perQueryLimit: placementMode ? 8 : 10,
|
||||
perQueryLimit: 15,
|
||||
placementMode,
|
||||
concurrency: 2,
|
||||
tagMeta,
|
||||
|
|
@ -270,9 +270,10 @@ export async function runScanForTopic(
|
|||
},
|
||||
}),
|
||||
!placementMode && accountWebTargets.length > 0
|
||||
? discoverPostsFromSimilarAccounts(accountWebTargets.slice(0, 8), {
|
||||
perAccountLimit: placementMode ? 12 : 10,
|
||||
? discoverPostsFromSimilarAccounts(accountWebTargets.slice(0, 4), {
|
||||
perAccountLimit: 20,
|
||||
placementMode,
|
||||
keywordPriority: braveKeywordPriority,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
|
@ -839,10 +840,13 @@ export async function runScanForTopic(
|
|||
await applyQualityFilter(scan.id);
|
||||
|
||||
const visibleCount = await prisma.scanItem.count({
|
||||
where: { scanId: scan.id, qualityTier: { not: "EXCLUDE" } },
|
||||
where: { scanId: scan.id, OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||
});
|
||||
|
||||
setTaskStatus(progressDetail, "quality", { status: "done", found: visibleCount });
|
||||
|
||||
await enrichAccountsFromScan(scan.id, topic.id);
|
||||
|
||||
progressDetail.summary = `完成 · ${ranked.length} 篇 · ${repliesCount} 則留言`;
|
||||
await report(progressDetail.summary, progressDetail);
|
||||
|
||||
|
|
@ -902,6 +906,86 @@ export async function applyQualityFilter(scanId: string) {
|
|||
return [];
|
||||
}
|
||||
|
||||
async function enrichAccountsFromScan(scanId: string, topicId: string) {
|
||||
const scan = await prisma.scan.findUnique({
|
||||
where: { id: scanId },
|
||||
include: {
|
||||
items: {
|
||||
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||
orderBy: { combinedScore: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!scan || scan.items.length === 0) return;
|
||||
|
||||
const cutoff = Math.max(3, Math.ceil(scan.items.length * 0.3));
|
||||
const topItems = scan.items.slice(0, cutoff);
|
||||
const authorMap = new Map<string, { count: number; maxScore: number; latestPost: Date; reason: string }>();
|
||||
|
||||
for (const item of topItems) {
|
||||
if (!item.authorName) continue;
|
||||
const key = item.authorName.toLowerCase();
|
||||
const existing = authorMap.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
if ((item.combinedScore ?? item.score) > existing.maxScore) {
|
||||
existing.maxScore = item.combinedScore ?? item.score;
|
||||
}
|
||||
if (item.postedAt && item.postedAt > existing.latestPost) {
|
||||
existing.latestPost = item.postedAt;
|
||||
}
|
||||
} else {
|
||||
authorMap.set(key, {
|
||||
count: 1,
|
||||
maxScore: item.combinedScore ?? item.score,
|
||||
latestPost: item.postedAt ?? new Date(0),
|
||||
reason: item.qualityReason || item.text.slice(0, 80) || "海巡發現的高品質作者",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const topic = await prisma.topic.findUnique({ where: { id: topicId } });
|
||||
if (!topic) return;
|
||||
|
||||
const existingMap = parseResearchMap(topic.researchMap);
|
||||
if (!existingMap) return;
|
||||
|
||||
const existingAccounts = existingMap.similarAccounts ?? [];
|
||||
const existingByKey = new Map(existingAccounts.map((a) => [a.username.toLowerCase(), a]));
|
||||
|
||||
const newAccounts: SimilarAccount[] = [];
|
||||
for (const [key, data] of authorMap) {
|
||||
const existing = existingByKey.get(key);
|
||||
if (existing) {
|
||||
if (!existing.confidence || existing.confidence === "low") {
|
||||
existing.confidence = data.count >= 2 ? "high" : "medium";
|
||||
}
|
||||
if (data.latestPost > new Date(existing.lastActiveAt ?? 0)) {
|
||||
existing.lastActiveAt = data.latestPost.toISOString();
|
||||
}
|
||||
existingByKey.set(key, existing);
|
||||
} else {
|
||||
newAccounts.push({
|
||||
username: key,
|
||||
reason: data.reason,
|
||||
source: "scan",
|
||||
profileUrl: threadsProfileUrl(key) ?? undefined,
|
||||
confidence: data.count >= 2 ? "high" : "medium",
|
||||
lastActiveAt: data.latestPost.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newAccounts.length === 0) return;
|
||||
|
||||
const merged = [...newAccounts, ...existingByKey.values()];
|
||||
|
||||
await prisma.topic.update({
|
||||
where: { id: topicId },
|
||||
data: { researchMap: JSON.stringify({ ...existingMap, similarAccounts: merged }) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function runScanForAllActiveTopics(accountId?: string | null) {
|
||||
const topics = await prisma.topic.findMany({
|
||||
where: { active: true, ...(accountId ? { accountId } : {}) },
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export async function analyzeScanTopViral(scanId: string, limit = 5) {
|
|||
const items = await prisma.scanItem.findMany({
|
||||
where: {
|
||||
scanId,
|
||||
qualityTier: { not: "EXCLUDE" },
|
||||
OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }],
|
||||
},
|
||||
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
||||
take: limit,
|
||||
|
|
|
|||
|
|
@ -31,14 +31,17 @@ export interface SuggestedTag {
|
|||
searchType?: SearchTagType;
|
||||
}
|
||||
|
||||
export type AccountConfidence = "high" | "medium" | "low";
|
||||
|
||||
export interface SimilarAccount {
|
||||
username: string;
|
||||
reason: string;
|
||||
/** web=網路搜尋找到的真實連結;threads=Threads 關鍵字搜尋熱門作者 */
|
||||
source?: "web" | "threads" | "scan";
|
||||
profileUrl?: string;
|
||||
/** 發現此帳號時參考的那篇 Threads 貼文(若有) */
|
||||
postUrl?: string;
|
||||
confidence?: AccountConfidence;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
export interface ResearchMap {
|
||||
|
|
@ -125,4 +128,13 @@ export const SIMILAR_ACCOUNT_SOURCE_LABELS: Record<
|
|||
web: "網路搜尋",
|
||||
threads: "Threads 搜尋",
|
||||
scan: "海巡發現",
|
||||
};
|
||||
|
||||
export const SIMILAR_ACCOUNT_CONFIDENCE_LABELS: Record<
|
||||
NonNullable<SimilarAccount["confidence"]>,
|
||||
string
|
||||
> = {
|
||||
high: "高",
|
||||
medium: "中",
|
||||
low: "低",
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue