Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions frontend/components/flow/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@ const PALETTE_COPY = {
noNodes: "没有匹配的节点",
opencliCapabilities: "OpenCLI 能力预设",
loadingOpencli: "正在读取 OpenCLI 能力目录",
featuredSites: "国内全网 OODA 数据源",
featuredSites: "A 股数据源",
catalogIndex: "能力导航",
featuredDescription: "按 OODA 使用场景浏览常用国内数据源",
featuredDescription: "按行情、公告、新闻、社区与视频浏览可用来源",
moreOpencliPresets: "更多站点能力",
pluginTools: "插件与后端工具",
noTools: "没有匹配的工具",
Expand Down Expand Up @@ -221,9 +221,9 @@ const PALETTE_COPY = {
noNodes: "No matching nodes",
opencliCapabilities: "OpenCLI capability presets",
loadingOpencli: "Loading the OpenCLI capability catalog",
featuredSites: "China-wide OODA sources",
featuredSites: "A-share data sources",
catalogIndex: "Capability navigation",
featuredDescription: "Browse common China data sources by OODA use case",
featuredDescription: "Browse market, filing, news, social, and video sources",
moreOpencliPresets: "More site capabilities",
pluginTools: "Plugin & backend tools",
noTools: "No matching tools",
Expand Down
192 changes: 168 additions & 24 deletions frontend/components/flow/inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
GitBranch,
Plus,
PlugZap,
RotateCcw,
Search,
Trash2,
Unplug,
Expand Down Expand Up @@ -68,6 +69,10 @@ import {
isOpenCLISourceSlotArray,
type OpenCLISourceSlot,
} from "@/lib/workflow/node-catalog"
import {
ASHARE_OPENCLI_SOURCES,
OPENCLI_SITUATION_SOURCES,
} from "@/lib/workflow/opencli-business-workflows"
import {
openCLISlotFromDataSource,
SOURCE_ARGUMENT_LABELS,
Expand Down Expand Up @@ -106,6 +111,28 @@ const edgeTypeHints: Record<string, string> = {
routed: "自动绕开中间节点的正交折线,适合密集流程图。",
}

const BUILT_IN_SOURCE_IDS = new Set([
...ASHARE_OPENCLI_SOURCES,
...OPENCLI_SITUATION_SOURCES,
].map((source) => source.id))

const SOURCE_ID_ACRONYMS: Record<string, string> = {
bse: "BSE",
cninfo: "CNInfo",
pdf: "PDF",
sse: "SSE",
szse: "SZSE",
ths: "THS",
}

function sourceCardLabel(source: OpenCLISourceSlot, language: WorkflowLanguage): string {
if (language === "zh-CN" || !BUILT_IN_SOURCE_IDS.has(source.id)) return source.label
return source.id
.split("-")
.map((part) => SOURCE_ID_ACRONYMS[part] ?? `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ")
Comment on lines +128 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve customized built-in source names in English.

The advanced editor permits changing source.label, but this function always replaces built-in labels with an ID-derived value in English. Only derive an English default when the label still matches the preset default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/flow/inspector.tsx` around lines 128 - 133, Update
sourceCardLabel so English output preserves customized source.label values for
built-in sources; derive the ID-based label only when source.label still matches
that source’s preset default. Keep the existing Chinese behavior and
non-built-in label handling unchanged, using the established built-in source
defaults for comparison.

}

const INSPECTOR_COPY = {
"zh-CN": {
switchLanguage: "切换节点语言",
Expand Down Expand Up @@ -173,6 +200,20 @@ const INSPECTOR_COPY = {
collectionTopicPlaceholder: "例如:人工智能、贵州茅台",
collectionTopicHint: "一次设置会同步到所有搜索型来源。",
market: "市场范围",
contentType: "采集内容",
addContent: "添加一类来源",
contentTypes: {
market: "行情",
filings: "公告财报",
macro: "宏观",
news: "新闻",
social: "社区舆情",
video: "视频",
},
configureSource: "设置",
positionalArgument: "搜索词 / 资源 ID",
removedSource: "已移除",
undoRemove: "撤销",
collectionOptions: "采集选项",
items: "项",
opencliMapping: "OpenCLI 映射",
Expand Down Expand Up @@ -251,6 +292,20 @@ const INSPECTOR_COPY = {
collectionTopicPlaceholder: "Example: artificial intelligence, Apple",
collectionTopicHint: "One value is synchronized to every search-based source.",
market: "Market scope",
contentType: "Content",
addContent: "Add a source group",
contentTypes: {
market: "Market",
filings: "Filings",
macro: "Macro",
news: "News",
social: "Social",
video: "Video",
},
configureSource: "Configure",
positionalArgument: "Search term / resource ID",
removedSource: "Removed",
undoRemove: "Undo",
collectionOptions: "Collection options",
items: "items",
opencliMapping: "OpenCLI mapping",
Expand Down Expand Up @@ -1527,6 +1582,7 @@ export function Inspector({ compact = false, onClose }: { compact?: boolean; onC

{openCLISources ? (
<OpenCLISourceEditor
key={configurationNodeId}
sources={openCLISources}
language={language}
onChange={(sources) => updateWorkflowNodeParams(configurationNodeId, { sources })}
Expand Down Expand Up @@ -1886,6 +1942,7 @@ function OpenCLISourceEditor({
const availableSources = registeredSources.filter((source) => !selectedSourceKeys.has(sourceSlotKey(source)))
const businessQuery = sourceBusinessQuery(sources)
const market = sourceMarket(sources)
const [removedSource, setRemovedSource] = useState<{ source: OpenCLISourceSlot; index: number } | null>(null)

const updateSource = (index: number, patch: Partial<OpenCLISourceSlot>) => {
onChange(sources.map((source, sourceIndex) => (
Expand All @@ -1899,6 +1956,34 @@ function OpenCLISourceEditor({
onChange([...sources, source])
}

const addContentSources = (contentType: string | null) => {
if (!contentType) return
const presets = contentType === "video"
? OPENCLI_SITUATION_SOURCES.filter((source) => source.sourceGroup?.startsWith("video-"))
: ASHARE_OPENCLI_SOURCES.filter((source) => source.sourceGroup === contentType)
const selectedKeys = new Set(sources.map(sourceSlotKey))
const additions = presets.filter((source) => !selectedKeys.has(sourceSlotKey(source)))
if (additions.length > 0) onChange([...sources, ...additions])
}

const removeSource = (index: number) => {
setRemovedSource({ source: sources[index], index })
onChange(sources.filter((_, sourceIndex) => sourceIndex !== index))
}

const restoreSource = () => {
if (!removedSource) return
const removedKey = sourceSlotKey(removedSource.source)
if (sources.some((source) => sourceSlotKey(source) === removedKey)) {
setRemovedSource(null)
return
}
const restored = [...sources]
restored.splice(Math.min(removedSource.index, restored.length), 0, removedSource.source)
onChange(restored)
setRemovedSource(null)
}
Comment on lines +1959 to +1985

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Deduplicate preset additions and undo by immutable source ID.

sourceSlotKey() includes mutable args. After changing a preset’s market, re-adding its group treats the original preset as new; undo can likewise restore a second slot with the same id. This duplicates collection and produces duplicate React card keys. Compare source.id for these preset/undo operations.

Proposed fix
-    const selectedKeys = new Set(sources.map(sourceSlotKey))
-    const additions = presets.filter((source) => !selectedKeys.has(sourceSlotKey(source)))
+    const selectedIds = new Set(sources.map((source) => source.id))
+    const additions = presets.filter((source) => !selectedIds.has(source.id))
@@
-    const removedKey = sourceSlotKey(removedSource.source)
-    if (sources.some((source) => sourceSlotKey(source) === removedKey)) {
+    if (sources.some((source) => source.id === removedSource.source.id)) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const addContentSources = (contentType: string | null) => {
if (!contentType) return
const presets = contentType === "video"
? OPENCLI_SITUATION_SOURCES.filter((source) => source.sourceGroup?.startsWith("video-"))
: ASHARE_OPENCLI_SOURCES.filter((source) => source.sourceGroup === contentType)
const selectedKeys = new Set(sources.map(sourceSlotKey))
const additions = presets.filter((source) => !selectedKeys.has(sourceSlotKey(source)))
if (additions.length > 0) onChange([...sources, ...additions])
}
const removeSource = (index: number) => {
setRemovedSource({ source: sources[index], index })
onChange(sources.filter((_, sourceIndex) => sourceIndex !== index))
}
const restoreSource = () => {
if (!removedSource) return
const removedKey = sourceSlotKey(removedSource.source)
if (sources.some((source) => sourceSlotKey(source) === removedKey)) {
setRemovedSource(null)
return
}
const restored = [...sources]
restored.splice(Math.min(removedSource.index, restored.length), 0, removedSource.source)
onChange(restored)
setRemovedSource(null)
}
const addContentSources = (contentType: string | null) => {
if (!contentType) return
const presets = contentType === "video"
? OPENCLI_SITUATION_SOURCES.filter((source) => source.sourceGroup?.startsWith("video-"))
: ASHARE_OPENCLI_SOURCES.filter((source) => source.sourceGroup === contentType)
const selectedIds = new Set(sources.map((source) => source.id))
const additions = presets.filter((source) => !selectedIds.has(source.id))
if (additions.length > 0) onChange([...sources, ...additions])
}
const removeSource = (index: number) => {
setRemovedSource({ source: sources[index], index })
onChange(sources.filter((_, sourceIndex) => sourceIndex !== index))
}
const restoreSource = () => {
if (!removedSource) return
if (sources.some((source) => source.id === removedSource.source.id)) {
setRemovedSource(null)
return
}
const restored = [...sources]
restored.splice(Math.min(removedSource.index, restored.length), 0, removedSource.source)
onChange(restored)
setRemovedSource(null)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/flow/inspector.tsx` around lines 1959 - 1985, Update
addContentSources and restoreSource to compare sources by immutable source.id
rather than sourceSlotKey(), so preset additions and undo cannot introduce
duplicate source IDs after mutable args change. Keep the existing ordering,
removal, and restoration behavior unchanged.


return (
<section className="overflow-hidden rounded-[3px] border border-[#20242a] bg-[#101216]/84">
<div className="space-y-3 border-b border-[#24282f] bg-[#171a1f] p-3">
Expand Down Expand Up @@ -1944,6 +2029,28 @@ function OpenCLISourceEditor({
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-medium text-foreground">{copy.contentType}</Label>
<Select onValueChange={addContentSources}>
<SelectTrigger
aria-label={copy.addContent}
className="h-8 rounded-[3px] border-[#303640] bg-[#080a0c] text-xs shadow-none focus:ring-0"
>
<Plus className="size-3" />
<SelectValue placeholder={copy.addContent} />
</SelectTrigger>
<SelectContent>
{Object.entries(copy.contentTypes).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] leading-relaxed text-muted-foreground">
{language === "zh-CN"
? "市场范围只影响行情类卡片;新闻、社区和视频按来源卡片独立配置。"
: "Market scope affects market cards only; news, social, and video are configured per source card."}
</p>
</div>
{sourceCatalog.isError ? (
<p className="text-[10px] leading-relaxed text-[#fca5a5]">
{copy.sourceUnavailable}
Expand Down Expand Up @@ -1998,36 +2105,57 @@ function OpenCLISourceEditor({
) : null}

<div className="space-y-2 p-2">
{removedSource ? (
<div role="status" className="flex items-center justify-between gap-3 rounded-[3px] border border-[#3a3327] bg-[#18140e] px-2.5 py-2 text-[10px] text-[#f7c77d]">
<span className="truncate">{copy.removedSource}: {sourceCardLabel(removedSource.source, language)}</span>
<button
type="button"
onClick={restoreSource}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-[2px] border border-[#6b5230] px-2 font-medium transition-colors hover:border-[#f7c77d] hover:text-[#ffe4b5]"
>
<RotateCcw className="size-3" />
{copy.undoRemove}
</button>
</div>
) : null}
{sources.map((source, index) => {
const businessArguments = sourceBusinessArguments(source)
const optionCount = businessArguments.length + (source.positionalArgs?.length ? 1 : 0)
const contentType = source.sourceGroup?.startsWith("video-") ? "video" : source.sourceGroup
const contentLabel = contentType && contentType in copy.contentTypes
? copy.contentTypes[contentType as keyof typeof copy.contentTypes]
: (language === "zh-CN" ? "数据采集" : "Data collection")
return (
<div key={source.id} className="rounded-[3px] border border-[#252a31] bg-[#090a0c]/70">
<div className="flex items-center gap-2 p-2.5">
<details key={source.id} className="group rounded-[3px] border border-[#252a31] bg-[#090a0c]/70 open:border-[#3a414c]">
<summary className="flex cursor-pointer list-none items-center gap-2 p-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-[3px] border border-[#343a43] bg-[#15181d] font-mono text-[11px] font-semibold uppercase text-[#ff9a4a]">
{source.site.slice(0, 1)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">{source.label}</p>
<p className="truncate text-xs font-medium text-foreground">{sourceCardLabel(source, language)}</p>
<p className="truncate text-[10px] text-muted-foreground">
{source.site} · {source.sourceGroup || (language === "zh-CN" ? "数据采集" : "Data collection")}
{source.site} · {contentLabel}
</p>
</div>
<button
type="button"
aria-label={`${copy.removeSource} ${source.label}`}
disabled={sources.length <= 1}
onClick={() => onChange(sources.filter((_, sourceIndex) => sourceIndex !== index))}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-[2px] border border-[#2c3036] text-muted-foreground transition-colors hover:border-[#7f1d1d] hover:text-[#f87171] disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 className="size-3" />
</button>
</div>
{businessArguments.length > 0 ? (
<details className="border-t border-[#20242a]">
<summary className="cursor-pointer list-none px-2.5 py-2 text-[10px] text-muted-foreground transition-colors hover:text-foreground">
{copy.collectionOptions} · {businessArguments.length} {copy.items}
</summary>
<div className="grid gap-2 border-t border-[#20242a] p-2.5">
<span className="shrink-0 text-[10px] text-muted-foreground">{copy.configureSource}</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
</summary>
<div className="grid gap-2 border-t border-[#20242a] p-2.5">
{optionCount > 0 ? (
<>
<p className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">
{copy.collectionOptions} · {optionCount} {copy.items}
</p>
{source.positionalArgs?.length ? (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">{copy.positionalArgument}</Label>
<Input
value={source.positionalArgs[0] ?? ""}
onChange={(event) => updateSource(index, { positionalArgs: [event.target.value] })}
className={houdiniInputClass}
/>
</div>
) : null}
{businessArguments.map(([key, value]) => (
<SourceBusinessArgument
key={key}
Expand All @@ -2037,10 +2165,26 @@ function OpenCLISourceEditor({
onChange={(nextValue) => updateSource(index, { args: { ...source.args, [key]: nextValue } })}
/>
))}
</div>
</details>
) : null}
</div>
</>
) : (
<p className="text-[10px] text-muted-foreground">
{language === "zh-CN" ? "此来源没有必须填写的业务参数。" : "This source has no required business parameters."}
</p>
)}
<div className="flex justify-end border-t border-[#20242a] pt-2">
<button
type="button"
aria-label={`${copy.removeSource} ${source.label}`}
disabled={sources.length <= 1}
onClick={() => removeSource(index)}
className="inline-flex h-7 items-center gap-1.5 rounded-[2px] border border-[#4a2525] px-2 text-[10px] text-[#f87171] transition-colors hover:border-[#f87171] disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 className="size-3" />
{copy.removeSource}
</button>
</div>
</div>
</details>
)
})}
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/workflow/opencli-business-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export function buildAshareMarketWorkflow(name: string) {
workflowId: "ashare-market-intelligence",
cadence: "5m",
sources: ASHARE_OPENCLI_SOURCES,
sourceLabel: "国内全网 OODA 数据源",
sourceLabel: "A 股数据源",
sourceDescription: "行情、公告与 PDF、宏观、新闻、社交五类来源并行采集;逐来源显示完成、空结果或失败",
recordsLabel: "A 股金融数据集",
maxItemsPerRun: 1_000,
Expand Down
4 changes: 2 additions & 2 deletions frontend/lib/workflow/source-business-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function updateSourceBusinessQuery(

export function sourceMarket(sources: OpenCLISourceSlot[]): string | undefined {
for (const source of sources) {
if (typeof source.args.market === "string") return source.args.market
if (source.sourceGroup === "market" && typeof source.args.market === "string") return source.args.market
}
return undefined
}
Expand All @@ -87,7 +87,7 @@ export function updateSourceMarket(
value: string,
): OpenCLISourceSlot[] {
return sources.map((source) => (
Object.prototype.hasOwnProperty.call(source.args, "market")
source.sourceGroup === "market" && Object.prototype.hasOwnProperty.call(source.args, "market")
? { ...source, args: { ...source.args, market: value } }
: source
))
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/workflow/studio-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { parseWorkflowProject, workflowNodeSchema, type WorkflowProjectNode } from './schema'

export const STUDIO_TEMPLATES = [
{ id: 'ashare-market-intelligence', variant: 'collection-to-consumption', appType: 'workflow', title: 'A 股真实金融数据采集', description: '用 OpenCLI 并行采集国内行情、公告财报、宏观快讯、财经媒体与社区热度,并写入可追溯 Records。', category: '真实业务测试', steps: ['国内全网 OODA 采集', '清洗与准入', '数据工作台'] },
{ id: 'ashare-market-intelligence', variant: 'collection-to-consumption', appType: 'workflow', title: 'A 股真实金融数据采集', description: '用 OpenCLI 并行采集国内行情、公告财报、宏观快讯、财经媒体与社区热度,并写入可追溯 Records。', category: '真实业务测试', steps: ['A 股数据源', '清洗与准入', '数据工作台'] },
{ id: 'opencli-situation-awareness', variant: 'collection-to-consumption', appType: 'workflow', title: 'OpenCLI 态势感知框架', description: '采集实时事件、新闻和视频字幕,保留证据血缘,并投影到数据工作台与逻辑证据页。', category: '真实业务测试', steps: ['多模态证据采集', '证据准入', '数据与证据工作台'] },
{ id: 'opencli-live-pipeline', variant: 'collection-to-consumption', appType: 'workflow', title: 'OpenCLI 实时采集清洗发送', description: '从 OpenCLI 动态数据源实时提取,完成标准化、去重、Records 入库并发送结果。', category: '完整链路', steps: ['OpenCLI 实时采集', '清洗与 Records', 'Webhook 发送'] },
{ id: 'financial-rss-intelligence', variant: 'collect', appType: 'workflow', title: '财经多源 RSS 情报', description: '并行采集央行政策、监管公告与研究动态,按来源 Group 清洗后写入成果与数据。', category: '采集与监控', steps: ['多源 RSS', 'Group 标准化', 'Records 入库'] },
Expand Down
27 changes: 26 additions & 1 deletion frontend/scripts/check-node-capability-catalog-regressions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ test('core catalog nodes and common parameters expose Chinese and English copy',
assert.equal(i18n.shouldPreserveNodeAuthoredText(customNodeData), true)
})

test('market scope changes market cards without rewriting filing source arguments', async () => {
const sourceConfig = await importTypeScript('lib/workflow/source-business-config.ts')
const sources = [
{ id: 'market', label: '行情', sourceGroup: 'market', site: 'eastmoney', command: 'gridlist', args: { market: 'hs-a' } },
{ id: 'filings', label: '公告', sourceGroup: 'filings', site: 'eastmoney', command: 'announcement', args: { market: 'SHA,SZA,BJA' } },
{ id: 'news', label: '新闻', sourceGroup: 'news', site: 'cls', command: 'telegraph', args: { limit: 30 } },
]

assert.equal(sourceConfig.sourceMarket(sources), 'hs-a')
const updated = sourceConfig.updateSourceMarket(sources, 'bj-a')
assert.equal(updated[0].args.market, 'bj-a')
assert.equal(updated[1].args.market, 'SHA,SZA,BJA')
assert.deepStrictEqual(updated[2].args, { limit: 30 })
})

test('backend node catalog overlays matching nodes without hiding runnable workflow capabilities', async () => {
const [{ mergeBackendNodeCapabilityCatalog }, nodeCatalog] = await Promise.all([
importTypeScript('lib/workflow/backend-node-capability-adapter.ts'),
Expand Down Expand Up @@ -419,7 +434,17 @@ test('Studio materializes every searchable OpenCLI capability preset as a node',
.map((group) => group.label),
['行情、资金与交易结构', '财报、公告、研报与 PDF', '财经媒体与实时快讯', '社交舆情与全网观察'],
)
assert.match(palette, /国内全网 OODA 数据源/)
assert.match(palette, /A 股数据源/)
assert.doesNotMatch(palette, /国内全网 OODA 数据源/)
assert.match(palette, /行情、公告、新闻、社区与视频/)
const inspector = await readFrontendSource('components/flow/inspector.tsx')
assert.match(inspector, /addContentSources/)
assert.match(inspector, /OPENCLI_SITUATION_SOURCES/)
assert.match(inspector, /restoreSource/)
assert.match(inspector, /key=\{configurationNodeId\}/)
assert.match(inspector, /presets\.filter\(\(source\) => !selectedKeys\.has\(sourceSlotKey\(source\)\)\)/)
assert.match(inspector, /sourceCardLabel/)
assert.match(inspector, /视频按来源卡片独立配置/)
assert.match(palette, /loginRequired:\s*"需登录"/)
assert.doesNotMatch(palette, /featuredOpenCLIAdapterNodes\(matchingOpenCLINodes\)\.filter/)
assert.equal(adapterCatalog.openCLIAdapterNodeToCatalogItem(sourcePreset).params.opencliAdapterNodeId, sourcePreset.id)
Expand Down