update
This commit is contained in:
126
src/pages/chat-dev/components/OpencodeChat.tsx
Normal file
126
src/pages/chat-dev/components/OpencodeChat.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { FileIcon, FolderIcon, RefreshCwIcon, TrashIcon } from 'lucide-react';
|
||||
import { useChatDevStore } from '../store';
|
||||
import { useCodeGraphStore } from '@/pages/code-graph/store';
|
||||
|
||||
export const OpencodeChat = () => {
|
||||
const {
|
||||
question, projectInfo,
|
||||
sessionId, isLoading,
|
||||
saveSessionInfo, loadSessionInfo, fetchSession, fetchMessages, clearSession,
|
||||
setData,
|
||||
} = useChatDevStore(
|
||||
useShallow((s) => ({
|
||||
question: s.question,
|
||||
projectInfo: s.projectInfo,
|
||||
setData: s.setData,
|
||||
sessionId: s.sessionId,
|
||||
isLoading: s.isLoading,
|
||||
saveSessionInfo: s.saveSessionInfo,
|
||||
loadSessionInfo: s.loadSessionInfo,
|
||||
fetchSession: s.fetchSession,
|
||||
fetchMessages: s.fetchMessages,
|
||||
clearSession: s.clearSession,
|
||||
})),
|
||||
);
|
||||
const codeGraphStore = useCodeGraphStore(useShallow((s) => ({
|
||||
createQuestion: s.createQuestion,
|
||||
url: s.url,
|
||||
})));
|
||||
|
||||
// 初始化后尝试从 sessionStorage 恢复 opencode session 并加载历史消息
|
||||
useEffect(() => {
|
||||
if (!codeGraphStore.url) return;
|
||||
const info = loadSessionInfo();
|
||||
if (info) {
|
||||
fetchSession(info.sessionId);
|
||||
fetchMessages(info.sessionId);
|
||||
}
|
||||
}, [codeGraphStore.url]);
|
||||
|
||||
const relativePath = projectInfo
|
||||
? (projectInfo.filepath || '').replace((projectInfo.projectPath || '') + '/', '') || '/'
|
||||
: null;
|
||||
|
||||
const onSend = async () => {
|
||||
const res = await codeGraphStore.createQuestion({
|
||||
question,
|
||||
projectPath: projectInfo?.projectPath,
|
||||
engine: 'opencode',
|
||||
sessionId: sessionId || undefined,
|
||||
});
|
||||
console.log(res);
|
||||
if (res?.code === 200 && res?.data) {
|
||||
const { sessionId: newSessionId, messageId: newMessageId } = res.data as any;
|
||||
if (newSessionId) {
|
||||
saveSessionInfo(newSessionId, newMessageId || '');
|
||||
fetchMessages(newSessionId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full max-w-2xl rounded-xl border border-white/10 bg-slate-900 shadow-2xl flex flex-col'>
|
||||
{/* Session 信息栏 */}
|
||||
{sessionId && (
|
||||
<div className='flex items-center gap-2 px-4 py-2 border-b border-white/10'>
|
||||
<span className='flex items-center gap-1 text-xs text-slate-400 ml-auto'>
|
||||
<span className='truncate max-w-[160px]' title={sessionId}>
|
||||
Session: {sessionId.slice(0, 8)}...
|
||||
</span>
|
||||
<button
|
||||
title='刷新消息'
|
||||
onClick={() => fetchMessages(sessionId)}
|
||||
className='p-1 rounded hover:text-emerald-400 transition-colors'>
|
||||
<RefreshCwIcon className='size-3' />
|
||||
</button>
|
||||
<button
|
||||
title='清除 Session'
|
||||
onClick={clearSession}
|
||||
className='p-1 rounded hover:text-red-400 transition-colors'>
|
||||
<TrashIcon className='size-3' />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className='px-4 py-4 flex flex-col gap-4'>
|
||||
{/* 节点信息 */}
|
||||
{relativePath && (
|
||||
<div className='flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/60 border border-white/5 min-w-0'>
|
||||
<FileIcon className='size-4 shrink-0 text-slate-400' />
|
||||
<span className='text-xs text-slate-300 font-mono truncate' title={relativePath}>
|
||||
{relativePath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{projectInfo?.projectPath && !relativePath && (
|
||||
<div className='flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/60 border border-white/5 min-w-0'>
|
||||
<FolderIcon className='size-4 shrink-0 text-slate-400' />
|
||||
<span className='text-xs text-slate-300 font-mono truncate'>
|
||||
{projectInfo.projectPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 问题输入区 */}
|
||||
<textarea
|
||||
className='w-full rounded-lg border border-white/10 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none'
|
||||
rows={6}
|
||||
placeholder='请输入内容...'
|
||||
value={question}
|
||||
onChange={(e) => setData({ question: e.target.value })}
|
||||
/>
|
||||
|
||||
<button
|
||||
disabled={isLoading}
|
||||
className='w-full rounded-lg bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium py-2 transition-colors'
|
||||
onClick={onSend}>
|
||||
{isLoading ? '处理中...' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,35 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearch } from '@tanstack/react-router';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { BotIcon, FileIcon, FolderIcon } from 'lucide-react';
|
||||
import { BotIcon } from 'lucide-react';
|
||||
import { useChatDevStore } from './store';
|
||||
import { BOT_KEYS, BotKey } from '@/pages/code-graph/store/bot-helper';
|
||||
import openclawSvg from '@/pages/code-graph/assets/openclaw.svg';
|
||||
import opencodePng from '@/pages/code-graph/assets/opencode.png';
|
||||
import { useCodeGraphStore } from '../code-graph/store';
|
||||
import { useLayoutStore } from '../auth/store';
|
||||
import { OpencodeChat } from './components/OpencodeChat';
|
||||
|
||||
const BOT_ICONS: Record<BotKey, string> = {
|
||||
openclaw: openclawSvg,
|
||||
opencode: opencodePng,
|
||||
};
|
||||
|
||||
const BOT_LABELS: Record<BotKey, string> = {
|
||||
openclaw: 'Openclaw',
|
||||
opencode: 'Opencode',
|
||||
};
|
||||
|
||||
export const App = () => {
|
||||
const { timestamp } = useSearch({ from: '/chat-dev' });
|
||||
const { question, engine, projectInfo, initFromTimestamp, setData } = useChatDevStore(
|
||||
const { engine, initFromTimestamp, setData } = useChatDevStore(
|
||||
useShallow((s) => ({
|
||||
question: s.question,
|
||||
engine: s.engine,
|
||||
projectInfo: s.projectInfo,
|
||||
initFromTimestamp: s.initFromTimestamp,
|
||||
setData: s.setData,
|
||||
})),
|
||||
);
|
||||
const layoutStore = useLayoutStore(useShallow((s) => ({
|
||||
me: s.me,
|
||||
})));
|
||||
const layoutStore = useLayoutStore(useShallow((s) => ({ me: s.me })));
|
||||
const codeGraphStore = useCodeGraphStore(useShallow((s) => ({
|
||||
createQuestion: s.createQuestion,
|
||||
init: s.init,
|
||||
})));
|
||||
|
||||
@@ -37,84 +38,45 @@ export const App = () => {
|
||||
if (!layoutStore.me?.username) return;
|
||||
codeGraphStore.init(layoutStore.me, { load: false });
|
||||
}, [layoutStore.me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timestamp) {
|
||||
initFromTimestamp(timestamp);
|
||||
}
|
||||
}, [timestamp]);
|
||||
|
||||
const relativePath = projectInfo
|
||||
? (projectInfo.filepath || '').replace((projectInfo.projectPath || '') + '/', '') || '/'
|
||||
: null;
|
||||
const onSend = async () => {
|
||||
if (projectInfo) {
|
||||
const res = await codeGraphStore.createQuestion({
|
||||
question,
|
||||
projectPath: projectInfo.projectPath,
|
||||
engine,
|
||||
});
|
||||
console.log(res);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className='h-full bg-slate-950 text-slate-100 flex flex-col items-center py-10 px-4'>
|
||||
<div className='w-full max-w-2xl rounded-xl border border-white/10 bg-slate-900 shadow-2xl flex flex-col'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center gap-2 px-4 py-3 border-b border-white/10'>
|
||||
<BotIcon className='size-4 text-emerald-400' />
|
||||
<span className='text-sm font-medium text-slate-100'>AI 助手</span>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className='px-4 py-4 flex flex-col gap-4'>
|
||||
{/* 节点信息 + Bot 切换 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
{relativePath && (
|
||||
<div className='flex-1 flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/60 border border-white/5 min-w-0'>
|
||||
<FileIcon className='size-4 shrink-0 text-slate-400' />
|
||||
<span className='text-xs text-slate-300 font-mono truncate' title={relativePath}>
|
||||
{relativePath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{projectInfo?.projectPath && !relativePath && (
|
||||
<div className='flex-1 flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/60 border border-white/5 min-w-0'>
|
||||
<FolderIcon className='size-4 shrink-0 text-slate-400' />
|
||||
<span className='text-xs text-slate-300 font-mono truncate'>
|
||||
{projectInfo.projectPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Bot 切换按钮组 */}
|
||||
<div className='flex items-center gap-1 shrink-0 ml-auto'>
|
||||
{BOT_KEYS.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
title={key}
|
||||
onClick={() => setData({ engine: key })}
|
||||
className={`p-1.5 rounded-lg border transition-colors ${engine === key
|
||||
? 'border-emerald-500/60 bg-emerald-500/10'
|
||||
: 'border-white/5 bg-slate-800/60 opacity-40 hover:opacity-70'
|
||||
}`}>
|
||||
<img src={BOT_ICONS[key]} alt={key} className='size-5 object-contain' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className='h-full bg-slate-950 text-slate-100 flex flex-col items-center py-10 px-4 overflow-auto'>
|
||||
<div className='w-full max-w-2xl flex flex-col gap-4'>
|
||||
{/* Bot 切换按钮 —— 最顶层 */}
|
||||
<div className='flex items-center gap-2 px-1'>
|
||||
<BotIcon className='size-4 text-emerald-400 shrink-0' />
|
||||
<span className='text-sm font-medium text-slate-400 mr-1'>Bot</span>
|
||||
<div className='flex items-center gap-1'>
|
||||
{BOT_KEYS.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
title={key}
|
||||
onClick={() => setData({ engine: key })}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
|
||||
engine === key
|
||||
? 'border-emerald-500/60 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-white/5 bg-slate-800/60 text-slate-400 opacity-50 hover:opacity-80'
|
||||
}`}>
|
||||
<img src={BOT_ICONS[key]} alt={key} className='size-4 object-contain' />
|
||||
{BOT_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 问题输入区 */}
|
||||
<textarea
|
||||
className='w-full rounded-lg border border-white/10 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none'
|
||||
rows={6}
|
||||
placeholder='请输入内容...'
|
||||
value={question}
|
||||
onChange={(e) => setData({ question: e.target.value })}
|
||||
/>
|
||||
|
||||
<button className='w-full rounded-lg bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 text-white text-sm font-medium py-2 transition-colors' onClick={onSend}>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bot 对应的聊天面板 */}
|
||||
{engine === 'opencode' && <OpencodeChat />}
|
||||
{engine === 'openclaw' && (
|
||||
<div className='w-full rounded-xl border border-white/10 bg-slate-900 shadow-2xl flex items-center justify-center py-16 text-slate-500 text-sm'>
|
||||
Openclaw 即将支持,敬请期待
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,116 @@
|
||||
import { create } from 'zustand';
|
||||
import { BotKey } from '@/pages/code-graph/store/bot-helper';
|
||||
import { queryApi as opencodeApi } from '@/modules/opencode-api';
|
||||
import { getApiUrl } from '@/pages/code-graph/store';
|
||||
export type ChatDevData = {
|
||||
question: string;
|
||||
engine: BotKey;
|
||||
projectInfo: {
|
||||
filepath: string;
|
||||
projectPath: string;
|
||||
kind: 'file' | 'dir' | 'root';
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SessionMessage = {
|
||||
info: any;
|
||||
parts: Array<any>;
|
||||
};
|
||||
|
||||
export type SessionInfo = {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
type ChatDevState = {
|
||||
question: string;
|
||||
engine: BotKey;
|
||||
projectInfo: {
|
||||
filepath: string;
|
||||
projectPath: string;
|
||||
kind?: 'file' | 'dir' | 'root';
|
||||
} | null;
|
||||
sessionId: string | null;
|
||||
messageId: string | null;
|
||||
messages: SessionMessage[];
|
||||
isLoading: boolean;
|
||||
setData: (data: Partial<ChatDevData>) => void;
|
||||
initFromTimestamp: (timestamp: string) => void;
|
||||
saveSessionInfo: (sessionId: string, messageId: string) => void;
|
||||
loadSessionInfo: () => SessionInfo | null;
|
||||
fetchSession: (sessionId: string) => Promise<void>;
|
||||
fetchMessages: (sessionId: string) => Promise<void>;
|
||||
clearSession: () => void;
|
||||
};
|
||||
|
||||
const SESSION_KEY_PREFIX = 'chat-dev-';
|
||||
const SESSION_KEY = 'chat-dev';
|
||||
const SESSION_INFO_KEY = 'chat-dev-session-info';
|
||||
|
||||
export const useChatDevStore = create<ChatDevState>()((set) => ({
|
||||
export const useChatDevStore = create<ChatDevState>()((set, get) => ({
|
||||
question: '',
|
||||
engine: 'opencode',
|
||||
projectInfo: null,
|
||||
sessionId: null,
|
||||
messageId: null,
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
|
||||
setData: (data) => set((s) => ({ ...s, ...data })),
|
||||
|
||||
saveSessionInfo: (sessionId: string, messageId: string) => {
|
||||
const info: SessionInfo = { sessionId, messageId };
|
||||
sessionStorage.setItem(SESSION_INFO_KEY, JSON.stringify(info));
|
||||
set({ sessionId, messageId });
|
||||
},
|
||||
|
||||
loadSessionInfo: () => {
|
||||
const raw = sessionStorage.getItem(SESSION_INFO_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as SessionInfo;
|
||||
} catch {
|
||||
sessionStorage.removeItem(SESSION_INFO_KEY);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
fetchSession: async (sessionId: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const url = getApiUrl();
|
||||
const res = await opencodeApi['opencode-session'].get({ id: sessionId }, { url });
|
||||
if (res.code === 200 && res.data) {
|
||||
set({ sessionId });
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchMessages: async (sessionId: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const url = getApiUrl();
|
||||
const res = await opencodeApi['opencode-session'].messages({ sessionId: sessionId }, { url });
|
||||
if (res.code === 200 && res.data) {
|
||||
const msgs: SessionMessage[] = Array.isArray(res.data) ? res.data : (res.data as any).list ?? [];
|
||||
set({ messages: msgs });
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearSession: () => {
|
||||
sessionStorage.removeItem(SESSION_INFO_KEY);
|
||||
set({ sessionId: null, messageId: null, messages: [] });
|
||||
},
|
||||
|
||||
initFromTimestamp: (timestamp: string) => {
|
||||
const key = SESSION_KEY_PREFIX + timestamp;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user