Переглянути джерело

feat: add HarmonyOS desktop project skeleton

- packages/kirincode-ohos/ — full DevEco Studio project
- ArkUI chat interface with sidebar + message list
- KirinCodeClient API service (HTTP to local server)
- 4-mode tab switching (build/plan/debug/solo)
- Supports HarmonyOS 6.0+ (API 12+) on 2in1/tablet/pc
KirinCode 1 місяць тому
батько
коміт
2167ef2054

+ 17 - 0
packages/kirincode-ohos/AppScope/app.json5

@@ -0,0 +1,17 @@
+{
+  "app": {
+    "bundleName": "ai.kirincode.ohos",
+    "vendor": "KirinCode",
+    "versionCode": 300,
+    "versionName": "0.3.0",
+    "icon": "$media:ic_app_icon",
+    "label": "$string:app_name",
+    "minAPIVersion": 12,
+    "targetAPIVersion": 14,
+    "apiReleaseType": "Release",
+    "debug": false,
+    "car": {
+      "minAPIVersion": 12
+    }
+  }
+}

+ 16 - 0
packages/kirincode-ohos/AppScope/resources/base/element/string.json

@@ -0,0 +1,16 @@
+{
+  "string": [
+    { "name": "app_name", "value": "KirinCode" },
+    { "name": "module_desc", "value": "KirinCode AI Coding Agent" },
+    { "name": "EntryAbility_desc", "value": "Main entry" },
+    { "name": "tab_build", "value": "Build" },
+    { "name": "tab_plan", "value": "Plan" },
+    { "name": "tab_debug", "value": "Debug" },
+    { "name": "tab_solo", "value": "Solo" },
+    { "name": "input_hint", "value": "Enter your prompt..." },
+    { "name": "send", "value": "Send" },
+    { "name": "connecting", "value": "Connecting..." },
+    { "name": "connected", "value": "Connected" },
+    { "name": "disconnected", "value": "Disconnected" }
+  ]
+}

+ 37 - 0
packages/kirincode-ohos/entry/src/main/ets/entryability/EntryAbility.ets

@@ -0,0 +1,37 @@
+import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit';
+import { hilog } from '@kit.PerformanceAnalysisKit';
+import { window } from '@kit.ArkUI';
+
+export default class EntryAbility extends UIAbility {
+  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
+    hilog.info(0x0001, 'KirinCode', 'EntryAbility onCreate');
+  }
+
+  onDestroy(): void {
+    hilog.info(0x0001, 'KirinCode', 'EntryAbility onDestroy');
+  }
+
+  onWindowStageCreate(windowStage: window.WindowStage): void {
+    hilog.info(0x0001, 'KirinCode', 'onWindowStageCreate');
+
+    windowStage.loadContent('pages/Index', (err) => {
+      if (err.code) {
+        hilog.error(0x0001, 'KirinCode', 'Load content failed: %{public}d', err.code);
+        return;
+      }
+      hilog.info(0x0001, 'KirinCode', 'Succeeded in loading content');
+    });
+  }
+
+  onWindowStageDestroy(): void {
+    hilog.info(0x0001, 'KirinCode', 'onWindowStageDestroy');
+  }
+
+  onForeground(): void {
+    hilog.info(0x0001, 'KirinCode', 'onForeground');
+  }
+
+  onBackground(): void {
+    hilog.info(0x0001, 'KirinCode', 'onBackground');
+  }
+}

+ 259 - 0
packages/kirincode-ohos/entry/src/main/ets/pages/Index.ets

@@ -0,0 +1,259 @@
+import { promptAction } from '@kit.ArkUI';
+import { KirinCodeClient, Message, Session } from '../service/KirinCodeClient';
+
+const client = new KirinCodeClient();
+
+const MODES = ['build', 'plan', 'debug', 'solo'] as const;
+type Mode = typeof MODES[number];
+
+@Entry
+@Component
+struct Index {
+  @State messages: Message[] = [];
+  @State inputText: string = '';
+  @State currentSession: Session | null = null;
+  @State connected: boolean = false;
+  @State connecting: boolean = true;
+  @State selectedMode: Mode = 'build';
+  @State sessions: Session[] = [];
+  @State showSidebar: boolean = true;
+
+  async aboutToAppear(): Promise<void> {
+    await this.checkConnection();
+    if (this.connected) {
+      await this.loadSessions();
+    }
+  }
+
+  async checkConnection(): Promise<void> {
+    this.connecting = true;
+    this.connected = await client.healthCheck();
+    this.connecting = false;
+  }
+
+  async loadSessions(): Promise<void> {
+    try {
+      this.sessions = await client.listSessions();
+    } catch (e) {
+      this.sessions = [];
+    }
+  }
+
+  async createNewSession(): Promise<void> {
+    try {
+      const session = await client.createSession(`New session - ${new Date().toLocaleTimeString()}`);
+      this.currentSession = session;
+      this.messages = [];
+      await this.loadSessions();
+    } catch (e) {
+      promptAction.showToast({ message: 'Failed to create session' });
+    }
+  }
+
+  async selectSession(session: Session): Promise<void> {
+    this.currentSession = session;
+    try {
+      this.messages = await client.getMessages(session.id);
+    } catch (e) {
+      this.messages = [];
+    }
+  }
+
+  async sendMessage(): Promise<void> {
+    const text = this.inputText.trim();
+    if (!text || !this.currentSession) return;
+
+    const userMsg: Message = {
+      id: `user_${Date.now()}`,
+      role: 'user',
+      content: text,
+      timestamp: Date.now()
+    };
+    this.messages = [...this.messages, userMsg];
+    this.inputText = '';
+
+    try {
+      await client.sendPrompt(this.currentSession.id, text, this.selectedMode);
+      const updated = await client.getMessages(this.currentSession.id);
+      this.messages = updated;
+    } catch (e) {
+      promptAction.showToast({ message: 'Failed to send message' });
+    }
+  }
+
+  build() {
+    Row() {
+      // Sidebar
+      if (this.showSidebar) {
+        Column() {
+          // Logo
+          Row() {
+            Text('🦄')
+              .fontSize(24)
+              .margin({ right: 8 })
+            Text('KirinCode')
+              .fontSize(20)
+              .fontWeight(FontWeight.Bold)
+              .fontColor('#e74c3c')
+          }
+          .width('100%')
+          .padding(16)
+          .border({ width: { bottom: 1 }, color: '#2c3e50' })
+
+          // New Session Button
+          Button('+ New Session')
+            .width('100%')
+            .margin({ left: 8, right: 8, top: 8, bottom: 8 })
+            .onClick(() => this.createNewSession())
+
+          // Session List
+          List({ space: 4 }) {
+            ForEach(this.sessions, (session: Session) => {
+              ListItem() {
+                Text(session.title)
+                  .maxLines(1)
+                  .textOverflow({ overflow: TextOverflow.Ellipsis })
+                  .fontColor(this.currentSession?.id === session.id ? '#f39c12' : '#ecf0f1')
+                  .padding(10)
+                  .width('100%')
+              }
+              .onClick(() => this.selectSession(session))
+            })
+          }
+          .layoutWeight(1)
+          .scrollBar(BarState.Off)
+
+          // Connection Status
+          Row() {
+            Text(this.connecting ? 'Connecting...' : (this.connected ? '🟢 Connected' : '🔴 Disconnected'))
+              .fontSize(12)
+              .fontColor('#7f8c8d')
+              .padding(12)
+          }
+          .width('100%')
+          .border({ width: { top: 1 }, color: '#2c3e50' })
+        }
+        .width(250)
+        .height('100%')
+        .backgroundColor('#1a1a2e')
+      }
+
+      // Main Chat Area
+      Column() {
+        // Toolbar
+        Row() {
+          // Mode Tabs
+          Row({ space: 0 }) {
+            ForEach(MODES, (mode: Mode) => {
+              Text(mode.charAt(0).toUpperCase() + mode.slice(1))
+                .fontSize(13)
+                .fontColor(this.selectedMode === mode ? '#ffffff' : '#7f8c8d')
+                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
+                .borderRadius(6)
+                .backgroundColor(this.selectedMode === mode ? '#e74c3c' : 'transparent')
+                .margin({ right: 4 })
+                .onClick(() => this.selectedMode = mode)
+            })
+          }
+
+          Blank()
+
+          // Toggle sidebar
+          Button(this.showSidebar ? '◀' : '▶')
+            .fontSize(16)
+            .backgroundColor('transparent')
+            .onClick(() => this.showSidebar = !this.showSidebar)
+        }
+        .width('100%')
+        .padding({ left: 16, right: 16, top: 10, bottom: 10 })
+        .border({ width: { bottom: 1 }, color: '#2c3e50' })
+        .backgroundColor('#16213e')
+
+        // Messages
+        if (!this.currentSession) {
+          // Empty state
+          Column() {
+            Text('🦄')
+              .fontSize(64)
+              .opacity(0.4)
+            Text('Welcome to KirinCode')
+              .fontSize(24)
+              .fontWeight(FontWeight.Bold)
+              .fontColor('#ecf0f1')
+              .margin({ top: 12 })
+            Text('Create a new session or select one to start')
+              .fontSize(14)
+              .fontColor('#7f8c8d')
+              .margin({ top: 8 })
+            Button('New Session')
+              .margin({ top: 24 })
+              .onClick(() => this.createNewSession())
+          }
+          .width('100%')
+          .height('100%')
+          .justifyContent(FlexAlign.Center)
+        } else {
+          // Message list
+          List({ space: 8 }) {
+            ForEach(this.messages, (msg: Message) => {
+              ListItem() {
+                Column() {
+                  Row() {
+                    Text(msg.role === 'user' ? '👤' : '🤖')
+                      .fontSize(16)
+                      .margin({ right: 8 })
+                    Text(msg.content)
+                      .fontSize(14)
+                      .fontColor(msg.role === 'user' ? '#ecf0f1' : '#bdc3c7')
+                      .layoutWeight(1)
+                      .textAlign(msg.role === 'user' ? TextAlign.End : TextAlign.Start)
+                  }
+                  .width('100%')
+                  .alignItems(VerticalAlign.Top)
+                }
+                .padding(12)
+                .borderRadius(8)
+                .backgroundColor(msg.role === 'user' ? '#2c3e5022' : '#16213e')
+                .width('90%')
+                .alignSelf(msg.role === 'user' ? ItemAlign.End : ItemAlign.Start)
+              }
+            })
+          }
+          .layoutWeight(1)
+          .scrollBar(BarState.Auto)
+          .padding(8)
+        }
+
+        // Input area
+        Row() {
+          TextInput({ placeholder: 'Enter your prompt...', text: this.inputText })
+            .layoutWeight(1)
+            .height(44)
+            .backgroundColor('#1a1a2e')
+            .borderRadius(8)
+            .border({ width: 1, color: '#2c3e50' })
+            .fontColor('#ecf0f1')
+            .placeholderColor('#7f8c8d')
+            .onChange((value: string) => this.inputText = value)
+            .onSubmit(() => this.sendMessage())
+
+          Button('Send')
+            .height(44)
+            .margin({ left: 8 })
+            .backgroundColor('#e74c3c')
+            .borderRadius(8)
+            .onClick(() => this.sendMessage())
+        }
+        .padding({ left: 16, right: 16, top: 10, bottom: 12 })
+        .border({ width: { top: 1 }, color: '#2c3e50' })
+        .width('100%')
+        .backgroundColor('#0f172a')
+      }
+      .layoutWeight(1)
+      .height('100%')
+      .backgroundColor('#0f172a')
+    }
+    .width('100%')
+    .height('100%')
+  }
+}

+ 147 - 0
packages/kirincode-ohos/entry/src/main/ets/service/KirinCodeClient.ets

@@ -0,0 +1,147 @@
+// KirinCode Server API client for HarmonyOS
+import { http } from '@kit.NetworkKit';
+import { BusinessError } from '@kit.BasicServicesKit';
+
+const BASE_URL = 'http://127.0.0.1:4096';
+
+export interface Message {
+  id: string
+  role: 'user' | 'assistant'
+  content: string
+  timestamp: number
+}
+
+export interface Session {
+  id: string
+  title: string
+  createdAt: number
+}
+
+export class KirinCodeClient {
+  private baseUrl: string;
+
+  constructor(baseUrl: string = BASE_URL) {
+    this.baseUrl = baseUrl;
+  }
+
+  async healthCheck(): Promise<boolean> {
+    return new Promise<boolean>((resolve) => {
+      const req = http.createHttp();
+      req.request(`${this.baseUrl}/global/health`, {
+        method: http.RequestMethod.GET,
+        connectTimeout: 5000,
+        readTimeout: 5000
+      }, (err: BusinessError, data: http.HttpResponse) => {
+        req.destroy();
+        if (err) {
+          resolve(false);
+          return;
+        }
+        resolve(data.responseCode === 200);
+      });
+    });
+  }
+
+  async listSessions(): Promise<Session[]> {
+    return new Promise<Session[]>((resolve, reject) => {
+      const req = http.createHttp();
+      req.request(`${this.baseUrl}/session`, {
+        method: http.RequestMethod.GET,
+        connectTimeout: 10000,
+        readTimeout: 10000
+      }, (err: BusinessError, data: http.HttpResponse) => {
+        req.destroy();
+        if (err) {
+          reject(err);
+          return;
+        }
+        try {
+          const body = JSON.parse(data.result as string);
+          resolve(body.data || []);
+        } catch (e) {
+          reject(e);
+        }
+      });
+    });
+  }
+
+  async createSession(title: string): Promise<Session> {
+    return new Promise<Session>((resolve, reject) => {
+      const req = http.createHttp();
+      req.request(`${this.baseUrl}/session`, {
+        method: http.RequestMethod.POST,
+        header: { 'Content-Type': 'application/json' },
+        extraData: JSON.stringify({ title }),
+        connectTimeout: 10000,
+        readTimeout: 10000
+      }, (err: BusinessError, data: http.HttpResponse) => {
+        req.destroy();
+        if (err) {
+          reject(err);
+          return;
+        }
+        try {
+          const body = JSON.parse(data.result as string);
+          resolve(body.data);
+        } catch (e) {
+          reject(e);
+        }
+      });
+    });
+  }
+
+  async sendPrompt(sessionId: string, text: string, agent?: string): Promise<void> {
+    return new Promise<void>((resolve, reject) => {
+      const req = http.createHttp();
+      const body: Record<string, Object> = {
+        parts: [{ type: 'text', text: text }]
+      };
+      if (agent) {
+        body['agent'] = agent;
+      }
+      req.request(`${this.baseUrl}/session/${sessionId}/prompt`, {
+        method: http.RequestMethod.POST,
+        header: { 'Content-Type': 'application/json' },
+        extraData: JSON.stringify(body),
+        connectTimeout: 30000,
+        readTimeout: 30000
+      }, (err: BusinessError, _data: http.HttpResponse) => {
+        req.destroy();
+        if (err) {
+          reject(err);
+          return;
+        }
+        resolve();
+      });
+    });
+  }
+
+  async getMessages(sessionId: string): Promise<Message[]> {
+    return new Promise<Message[]>((resolve, reject) => {
+      const req = http.createHttp();
+      req.request(`${this.baseUrl}/session/${sessionId}/messages`, {
+        method: http.RequestMethod.GET,
+        connectTimeout: 10000,
+        readTimeout: 10000
+      }, (err: BusinessError, data: http.HttpResponse) => {
+        req.destroy();
+        if (err) {
+          reject(err);
+          return;
+        }
+        try {
+          const body = JSON.parse(data.result as string);
+          const msgs = (body.data || []).map((m: Record<string, Object>) => ({
+            id: m.info?.id || '',
+            role: m.info?.role || 'user',
+            content: (m.parts || []).filter((p: Record<string, Object>) => p.type === 'text').map((p: Record<string, Object>) => p.text).join('\n'),
+            timestamp: m.info?.createdAt || Date.now()
+          }));
+          resolve(msgs);
+        } catch (e) {
+          reject(e);
+        }
+      });
+    });
+  }
+}

+ 40 - 0
packages/kirincode-ohos/entry/src/main/module.json5

@@ -0,0 +1,40 @@
+{
+  "module": {
+    "name": "entry",
+    "type": "entry",
+    "description": "$string:module_desc",
+    "mainElement": "EntryAbility",
+    "deviceTypes": ["2in1", "tablet", "pc"],
+    "deliveryWithInstall": true,
+    "installationFree": false,
+    "pages": "$profile:main_pages",
+    "abilities": [
+      {
+        "name": "EntryAbility",
+        "srcEntry": "./ets/entryability/EntryAbility.ets",
+        "description": "$string:EntryAbility_desc",
+        "icon": "$media:ic_app_icon",
+        "label": "$string:app_name",
+        "startWindowIcon": "$media:ic_app_icon",
+        "startWindowBackground": "$color:start_window_bg",
+        "exported": true,
+        "skills": [
+          {
+            "entities": ["entity.system.home"],
+            "actions": ["ohos.want.action.home"]
+          }
+        ],
+        "minWindowWidth": 800,
+        "minWindowHeight": 600,
+        "supportWindowMode": ["fullscreen", "floating", "split"]
+      }
+    ],
+    "requestPermissions": [
+      {
+        "name": "ohos.permission.INTERNET",
+        "reason": "$string:perm_internet_reason",
+        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
+      }
+    ]
+  }
+}

+ 5 - 0
packages/kirincode-ohos/entry/src/main/resources/base/profile/main_pages.json

@@ -0,0 +1,5 @@
+{
+  "src": [
+    "pages/Index"
+  ]
+}