feat: ai's attempt
This commit is contained in:
parent
2a9281c9dc
commit
0594fef9ac
|
|
@ -6,6 +6,7 @@ import './md-pins';
|
|||
import './md-bg';
|
||||
import './md-deck';
|
||||
import './md-commander/index';
|
||||
import './md-yarn-spinner';
|
||||
|
||||
// 导出组件
|
||||
export { Article } from './Article';
|
||||
|
|
@ -18,6 +19,7 @@ export { FileTreeNode, HeadingNode } from './FileTree';
|
|||
export type { DiceProps } from './md-dice';
|
||||
export type { TableProps } from './md-table';
|
||||
export type { BgProps } from './md-bg';
|
||||
export type { YarnSpinnerProps } from './md-yarn-spinner';
|
||||
|
||||
// 导出 md-commander 相关
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
import { customElement, noShadowDOM } from 'solid-element';
|
||||
import { createSignal, createMemo, createResource, createEffect, For, Show } from 'solid-js';
|
||||
import { parseYarn, compile, YarnRunner } from '../yarn-spinner';
|
||||
import type { RunnerOptions } from '../yarn-spinner/runtime/runner';
|
||||
import type { RuntimeResult, TextResult, OptionsResult, CommandResult } from '../yarn-spinner/runtime/results';
|
||||
|
||||
export interface YarnSpinnerProps extends RunnerOptions {
|
||||
startNode?: string;
|
||||
multipleFiles?: boolean;
|
||||
}
|
||||
|
||||
interface DialogueEntry {
|
||||
id: number;
|
||||
result: RuntimeResult;
|
||||
selectedOptionIndex?: number;
|
||||
}
|
||||
|
||||
// 缓存已加载的 yarn 文件内容
|
||||
const yarnCache = new Map<string, string>();
|
||||
|
||||
// 加载 yarn 文件内容
|
||||
async function loadYarnFile(path: string): Promise<string> {
|
||||
if (yarnCache.has(path)) {
|
||||
return yarnCache.get(path)!;
|
||||
}
|
||||
const response = await fetch(path);
|
||||
const content = await response.text();
|
||||
yarnCache.set(path, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
// 加载多个 yarn 文件并拼接
|
||||
async function loadYarnFiles(paths: string[]): Promise<string> {
|
||||
const contents = await Promise.all(paths.map(path => loadYarnFile(path)));
|
||||
return contents.join('\n');
|
||||
}
|
||||
|
||||
customElement('md-yarn-spinner', {
|
||||
startNode: undefined as string | undefined,
|
||||
multipleFiles: false
|
||||
}, (props, { element }) => {
|
||||
noShadowDOM();
|
||||
|
||||
const [dialogueHistory, setDialogueHistory] = createSignal<DialogueEntry[]>([]);
|
||||
const [currentOptions, setCurrentOptions] = createSignal<OptionsResult | null>(null);
|
||||
const [isEnded, setIsEnded] = createSignal(false);
|
||||
const [entryIdCounter, setEntryIdCounter] = createSignal(0);
|
||||
const [runnerInstance, setRunnerInstance] = createSignal<YarnRunner | null>(null);
|
||||
|
||||
// 获取文件路径
|
||||
const rawSrc = element?.textContent?.trim() || '';
|
||||
const articleEl = element?.closest('article[data-src]');
|
||||
const articlePath = articleEl?.getAttribute('data-src') || '';
|
||||
|
||||
// 隐藏原始文本内容
|
||||
if (element) {
|
||||
element.textContent = '';
|
||||
}
|
||||
|
||||
// 解析路径列表(支持多文件)
|
||||
const yarnPaths = createMemo(() => {
|
||||
const src = rawSrc;
|
||||
if (!src) return [];
|
||||
if (props.multipleFiles) {
|
||||
return src.split('\n').map((s: string) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return [src];
|
||||
});
|
||||
|
||||
// 解析相对路径
|
||||
const resolvedPaths = createMemo(() => {
|
||||
return yarnPaths().map((path: string) => {
|
||||
if (path.startsWith('/')) return path;
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
|
||||
const baseParts = articlePath.split('/').filter(Boolean);
|
||||
if (baseParts.length > 0 && !articlePath.endsWith('/')) {
|
||||
baseParts.pop();
|
||||
}
|
||||
|
||||
const relativeParts = path.split('/');
|
||||
for (const part of relativeParts) {
|
||||
if (part === '.' || part === '') continue;
|
||||
else if (part === '..') baseParts.pop();
|
||||
else baseParts.push(part);
|
||||
}
|
||||
|
||||
return '/' + baseParts.join('/');
|
||||
});
|
||||
});
|
||||
|
||||
// 加载 yarn 内容
|
||||
const [yarnContent] = createResource(
|
||||
() => resolvedPaths(),
|
||||
async (paths) => {
|
||||
if (paths.length === 0) return '';
|
||||
if (paths.length === 1) {
|
||||
return await loadYarnFile(paths[0]);
|
||||
}
|
||||
return await loadYarnFiles(paths);
|
||||
}
|
||||
);
|
||||
|
||||
// 创建 runner
|
||||
const createRunner = (content: string, startNode?: string) => {
|
||||
if (!content) return null;
|
||||
|
||||
try {
|
||||
const ast = parseYarn(content);
|
||||
const program = compile(ast);
|
||||
|
||||
const runnerOptions: RunnerOptions = {
|
||||
startAt: startNode || 'Start',
|
||||
variables: (props as YarnSpinnerProps).variables,
|
||||
functions: (props as YarnSpinnerProps).functions,
|
||||
handleCommand: (props as YarnSpinnerProps).handleCommand,
|
||||
onStoryEnd: (props as YarnSpinnerProps).onStoryEnd,
|
||||
};
|
||||
|
||||
return new YarnRunner(program, runnerOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize YarnRunner:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化 runner
|
||||
createEffect(() => {
|
||||
const content = yarnContent();
|
||||
if (content && !runnerInstance()) {
|
||||
const startNode = (props as YarnSpinnerProps).startNode;
|
||||
const runner = createRunner(content, startNode);
|
||||
if (runner) {
|
||||
setRunnerInstance(runner);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理 runner 输出
|
||||
const processRunnerOutput = (runner: YarnRunner) => {
|
||||
const result = runner.currentResult;
|
||||
if (!result) return;
|
||||
|
||||
const newEntry: DialogueEntry = {
|
||||
id: entryIdCounter(),
|
||||
result,
|
||||
};
|
||||
|
||||
setDialogueHistory(prev => [...prev, newEntry]);
|
||||
setEntryIdCounter(prev => prev + 1);
|
||||
|
||||
if (result.type === 'options') {
|
||||
setCurrentOptions(result as OptionsResult);
|
||||
} else if (result.type === 'text' && (result as TextResult).isDialogueEnd) {
|
||||
// 检查是否是对话结束
|
||||
const lastOptions = runner.history.slice().reverse().find(r => r.type === 'options') as OptionsResult | undefined;
|
||||
if (lastOptions && runner.currentResult?.type !== 'options') {
|
||||
// 之前有选项但没有选择,等待用户输入
|
||||
setCurrentOptions(lastOptions);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查故事是否结束
|
||||
if ((result as TextResult).isDialogueEnd && result.type !== 'options') {
|
||||
setIsEnded(true);
|
||||
setCurrentOptions(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 runner 变化
|
||||
createEffect(() => {
|
||||
const runner = runnerInstance();
|
||||
if (runner && runner.currentResult) {
|
||||
processRunnerOutput(runner);
|
||||
}
|
||||
});
|
||||
|
||||
// 选择选项
|
||||
const selectOption = (index: number) => {
|
||||
const runner = runnerInstance();
|
||||
if (!runner || !currentOptions()) return;
|
||||
|
||||
// 更新历史记录,标记已选择的选项
|
||||
setDialogueHistory(prev => {
|
||||
const lastOptionsIndex = prev.slice().reverse().findIndex(e => e.result.type === 'options');
|
||||
if (lastOptionsIndex === -1) return prev;
|
||||
|
||||
const actualIndex = prev.length - 1 - lastOptionsIndex;
|
||||
return prev.map((entry, i) =>
|
||||
i === actualIndex
|
||||
? { ...entry, selectedOptionIndex: index }
|
||||
: entry
|
||||
);
|
||||
});
|
||||
|
||||
setCurrentOptions(null);
|
||||
runner.advance(index);
|
||||
|
||||
// 处理选择后的输出
|
||||
setTimeout(() => {
|
||||
processRunnerOutput(runner);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 重新开始
|
||||
const restart = () => {
|
||||
setDialogueHistory([]);
|
||||
setCurrentOptions(null);
|
||||
setIsEnded(false);
|
||||
setEntryIdCounter(0);
|
||||
|
||||
// 重新创建 runner
|
||||
const content = yarnContent();
|
||||
if (!content) return;
|
||||
|
||||
const runner = createRunner(content, (props as YarnSpinnerProps).startNode);
|
||||
if (runner) {
|
||||
setRunnerInstance(runner);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染对话历史项
|
||||
const renderEntry = (entry: DialogueEntry) => {
|
||||
const result = entry.result;
|
||||
|
||||
if (result.type === 'text') {
|
||||
const textResult = result as TextResult;
|
||||
return (
|
||||
<div class="dialogue-entry text-entry">
|
||||
<Show when={textResult.speaker}>
|
||||
<span class="speaker font-bold text-blue-600">{textResult.speaker}: </span>
|
||||
</Show>
|
||||
<span class="text">{textResult.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.type === 'command') {
|
||||
return (
|
||||
<div class="dialogue-entry command-entry text-gray-500 italic">
|
||||
[{result.command}]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.type === 'options') {
|
||||
const optionsResult = result as OptionsResult;
|
||||
return (
|
||||
<div class="dialogue-entry options-entry">
|
||||
<Show when={entry.selectedOptionIndex !== undefined}>
|
||||
<div class="selected-option text-gray-600">
|
||||
→ {optionsResult.options[entry.selectedOptionIndex!].text}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={entry.selectedOptionIndex === undefined}>
|
||||
<div class="options-list flex flex-col gap-2">
|
||||
<For each={optionsResult.options}>
|
||||
{(option, index) => (
|
||||
<button
|
||||
onClick={() => selectOption(index())}
|
||||
class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100
|
||||
rounded border border-blue-200 transition-colors cursor-pointer"
|
||||
>
|
||||
→ {option.text}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="yarn-spinner w-full max-w-2xl mx-auto border rounded-lg shadow-sm">
|
||||
{/* 对话历史 */}
|
||||
<div class="dialogue-history p-4 min-h-[200px] max-h-[60vh] overflow-y-auto bg-gray-50">
|
||||
<Show when={dialogueHistory().length === 0 && !yarnContent.loading}>
|
||||
<div class="text-gray-400 text-center py-8">点击重新开始开始对话</div>
|
||||
</Show>
|
||||
<Show when={yarnContent.loading}>
|
||||
<div class="text-gray-400 text-center py-8">加载对话中...</div>
|
||||
</Show>
|
||||
<For each={dialogueHistory()}>
|
||||
{entry => renderEntry(entry)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* 当前选项 */}
|
||||
<Show when={currentOptions() && !isEnded()}>
|
||||
<div class="current-options p-4 border-t bg-white">
|
||||
<div class="options-list flex flex-col gap-2">
|
||||
<For each={currentOptions()?.options || []}>
|
||||
{(option, index) => (
|
||||
<button
|
||||
onClick={() => selectOption(index())}
|
||||
class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100
|
||||
rounded border border-blue-200 transition-colors cursor-pointer"
|
||||
>
|
||||
→ {option.text}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div class="toolbar p-2 border-t bg-gray-100 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={restart}
|
||||
class="restart-button px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300
|
||||
rounded transition-colors cursor-pointer"
|
||||
title="重新开始"
|
||||
>
|
||||
🔄 重新开始
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -80,3 +80,52 @@ icon{
|
|||
.col-3{ @apply lg:flex-3; }
|
||||
.col-4{ @apply lg:flex-4; }
|
||||
.col-5{ @apply lg:flex-5; }
|
||||
|
||||
/* yarn-spinner */
|
||||
|
||||
.yarn-spinner {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
.yarn-spinner .dialogue-history {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.yarn-spinner .dialogue-entry {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
|
||||
.yarn-spinner .text-entry {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.yarn-spinner .speaker {
|
||||
@apply text-blue-600 font-semibold;
|
||||
}
|
||||
|
||||
.yarn-spinner .command-entry {
|
||||
@apply text-sm text-gray-500 italic;
|
||||
}
|
||||
|
||||
.yarn-spinner .options-entry {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
.yarn-spinner .selected-option {
|
||||
@apply text-gray-600 font-medium;
|
||||
}
|
||||
|
||||
.yarn-spinner .option-button {
|
||||
@apply w-full text-left px-4 py-2.5 bg-blue-50 hover:bg-blue-100
|
||||
rounded border border-blue-200 transition-colors cursor-pointer
|
||||
text-gray-700 hover:text-gray-900;
|
||||
}
|
||||
|
||||
.yarn-spinner .toolbar {
|
||||
@apply bg-gray-50 border-t border-gray-200;
|
||||
}
|
||||
|
||||
.yarn-spinner .restart-button {
|
||||
@apply px-4 py-1.5 text-sm bg-gray-200 hover:bg-gray-300
|
||||
rounded transition-colors cursor-pointer text-gray-700;
|
||||
}
|
||||
Loading…
Reference in New Issue